refactor: switch codex runner from app-server to SDK
Replace raw JSON-RPC app-server protocol with @openai/codex-sdk. The SDK wraps `codex exec` which ensures complete task execution per turn, fixing the issue where app-server mode ended turns prematurely (agent saying "하겠습니다" without doing the work). Also fix AGENTS.md copy in agent-runner (was copying instructions.md which Codex CLI doesn't read).
This commit is contained in:
137
container/codex-runner/package-lock.json
generated
137
container/codex-runner/package-lock.json
generated
@@ -7,11 +7,148 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "nanoclaw-codex-runner",
|
"name": "nanoclaw-codex-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@openai/codex-sdk": "^0.114.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@openai/codex": {
|
||||||
|
"version": "0.114.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0.tgz",
|
||||||
|
"integrity": "sha512-HMo8LRR6CtfKkaa28xvFK6eOarmBFTDfsrS9GJtEoaspGGemFok494CpafDspiTZaHZZGHfSUe5SWTUxYq7OaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"codex": "bin/codex.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.114.0-darwin-arm64",
|
||||||
|
"@openai/codex-darwin-x64": "npm:@openai/codex@0.114.0-darwin-x64",
|
||||||
|
"@openai/codex-linux-arm64": "npm:@openai/codex@0.114.0-linux-arm64",
|
||||||
|
"@openai/codex-linux-x64": "npm:@openai/codex@0.114.0-linux-x64",
|
||||||
|
"@openai/codex-win32-arm64": "npm:@openai/codex@0.114.0-win32-arm64",
|
||||||
|
"@openai/codex-win32-x64": "npm:@openai/codex@0.114.0-win32-x64"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@openai/codex-darwin-arm64": {
|
||||||
|
"name": "@openai/codex",
|
||||||
|
"version": "0.114.0-darwin-arm64",
|
||||||
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-darwin-arm64.tgz",
|
||||||
|
"integrity": "sha512-c9dlgo9O+66alr3s3G36fKE3KaO2+xrLZ/QcU3oujDruV86pqgVSEK2njHi+WIyyOa6m2AyWxdaHLEbKxPCNRg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@openai/codex-darwin-x64": {
|
||||||
|
"name": "@openai/codex",
|
||||||
|
"version": "0.114.0-darwin-x64",
|
||||||
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-darwin-x64.tgz",
|
||||||
|
"integrity": "sha512-oPAPeZSaJZ1AQneFPSJu44uqbZr63Bxut9FOTWZwuSqYx4YEees7i0HJmJcqQc0XnCbYtHJdqo/i07Uv374NLw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@openai/codex-linux-arm64": {
|
||||||
|
"name": "@openai/codex",
|
||||||
|
"version": "0.114.0-linux-arm64",
|
||||||
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-linux-arm64.tgz",
|
||||||
|
"integrity": "sha512-oUFZGZgMiEqmo85C+dfhckpVApH25alLWfwBgfKr2IRgdx/tovy8vN101f6kj01E6nDDe4LfJdNSZGtZht4fRA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@openai/codex-linux-x64": {
|
||||||
|
"name": "@openai/codex",
|
||||||
|
"version": "0.114.0-linux-x64",
|
||||||
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-linux-x64.tgz",
|
||||||
|
"integrity": "sha512-MF6RhxfBoccccd5CYII2C7TL2TvYzombBQhanDZfQAajr6n7IuoazCmoHDlrFHS4AcSw9bYA4MRlrwEjbEjgsw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@openai/codex-sdk": {
|
||||||
|
"version": "0.114.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.114.0.tgz",
|
||||||
|
"integrity": "sha512-28tV+pvoQhhoRADBCtlg24fR6LDTyeUA6C65NBTvYFnDoQVPJcHcAwRrbuWmUMcXdLrXwY2bmiMxyfXlUWQzgw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@openai/codex": "0.114.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@openai/codex-win32-arm64": {
|
||||||
|
"name": "@openai/codex",
|
||||||
|
"version": "0.114.0-win32-arm64",
|
||||||
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-win32-arm64.tgz",
|
||||||
|
"integrity": "sha512-CnRMHopj3en9aqQ2UaDW7EgpEGkxHdZVLLRq2cOy5D0HyuzF6Qb6595ADoFVJHEmPeN5Iz/KUbiGs5GLDjUwOA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@openai/codex-win32-x64": {
|
||||||
|
"name": "@openai/codex",
|
||||||
|
"version": "0.114.0-win32-x64",
|
||||||
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-win32-x64.tgz",
|
||||||
|
"integrity": "sha512-ztRsH5Z+gPVZ5ZInx6HzXsTFFkuEdjk3Ay0UGVOhRRCWvevXQi/SL4eU8hwysCYrs8K/WRq3mo4c95w9zQ2wLw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.15",
|
"version": "22.19.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
|
"@openai/codex-sdk": "^0.114.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* NanoClaw Codex Runner (app-server mode)
|
* NanoClaw Codex Runner (SDK mode)
|
||||||
*
|
*
|
||||||
* Spawns a single `codex app-server` process and communicates via JSON-RPC
|
* Uses @openai/codex-sdk which wraps `codex exec`. This ensures complete
|
||||||
* over stdio. Supports streaming responses, session persistence (threadId),
|
* task execution per turn — the agent finishes all work before responding,
|
||||||
* and mid-turn message injection via turn/steer.
|
* unlike app-server mode which can end turns prematurely.
|
||||||
*
|
*
|
||||||
* Input protocol:
|
* Input protocol:
|
||||||
* Stdin: Full ContainerInput JSON (read until EOF)
|
* Stdin: Full ContainerInput JSON (read until EOF)
|
||||||
@@ -14,10 +14,9 @@
|
|||||||
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
|
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
import { Codex, type Thread, type UserInput, type ThreadOptions } from '@openai/codex-sdk';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import readline from 'readline';
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -39,20 +38,6 @@ interface ContainerOutput {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JsonRpcRequest {
|
|
||||||
method: string;
|
|
||||||
id?: number;
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JsonRpcResponse {
|
|
||||||
id?: number;
|
|
||||||
method?: string;
|
|
||||||
result?: Record<string, unknown>;
|
|
||||||
error?: { code: number; message: string };
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
|
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
|
||||||
@@ -146,412 +131,74 @@ function waitForIpcMessage(): Promise<string | null> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── App-Server Client ──────────────────────────────────────────────
|
// ── Input Parsing ─────────────────────────────────────────────────
|
||||||
|
|
||||||
class CodexAppServer {
|
|
||||||
private proc: ChildProcess;
|
|
||||||
private rl: readline.Interface;
|
|
||||||
private nextId = 1;
|
|
||||||
private pending = new Map<number, {
|
|
||||||
resolve: (value: JsonRpcResponse) => void;
|
|
||||||
reject: (err: Error) => void;
|
|
||||||
}>();
|
|
||||||
private notificationHandler: ((msg: JsonRpcResponse) => void) | null = null;
|
|
||||||
private serverRequestHandler: ((msg: JsonRpcResponse) => void) | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.proc = spawn('codex', ['app-server'], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
cwd: EFFECTIVE_CWD,
|
|
||||||
env: { ...process.env },
|
|
||||||
});
|
|
||||||
|
|
||||||
this.rl = readline.createInterface({ input: this.proc.stdout! });
|
|
||||||
|
|
||||||
this.rl.on('line', (line: string) => {
|
|
||||||
if (!line.trim()) return;
|
|
||||||
try {
|
|
||||||
const msg: JsonRpcResponse = JSON.parse(line);
|
|
||||||
this.handleMessage(msg);
|
|
||||||
} catch {
|
|
||||||
// Non-JSON output, ignore
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.proc.stderr?.on('data', (data: Buffer) => {
|
|
||||||
for (const line of data.toString().trim().split('\n')) {
|
|
||||||
if (line) log(line);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.proc.on('error', (err: Error) => {
|
|
||||||
log(`App-server spawn error: ${err.message}`);
|
|
||||||
// Reject all pending requests
|
|
||||||
for (const [, { reject }] of this.pending) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
this.pending.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.proc.on('close', (code: number | null) => {
|
|
||||||
log(`App-server exited with code ${code}`);
|
|
||||||
const err = new Error(`App-server exited with code ${code}`);
|
|
||||||
for (const [, { reject }] of this.pending) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
this.pending.clear();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMessage(msg: JsonRpcResponse): void {
|
|
||||||
// Response to a request we made
|
|
||||||
if (msg.id !== undefined && this.pending.has(msg.id)) {
|
|
||||||
const handler = this.pending.get(msg.id)!;
|
|
||||||
this.pending.delete(msg.id);
|
|
||||||
handler.resolve(msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server-initiated request (has id + method) — needs a response
|
|
||||||
if (msg.id !== undefined && msg.method) {
|
|
||||||
this.serverRequestHandler?.(msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notification (has method, no id)
|
|
||||||
if (msg.method) {
|
|
||||||
this.notificationHandler?.(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setNotificationHandler(handler: ((msg: JsonRpcResponse) => void) | null): void {
|
|
||||||
this.notificationHandler = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
setServerRequestHandler(handler: ((msg: JsonRpcResponse) => void) | null): void {
|
|
||||||
this.serverRequestHandler = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(msg: JsonRpcRequest): void {
|
|
||||||
this.proc.stdin!.write(JSON.stringify(msg) + '\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
async request(method: string, params: Record<string, unknown> = {}, timeoutMs = 30_000): Promise<JsonRpcResponse> {
|
|
||||||
const id = this.nextId++;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
this.pending.delete(id);
|
|
||||||
reject(new Error(`Request ${method} timed out after ${timeoutMs}ms`));
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
this.pending.set(id, {
|
|
||||||
resolve: (resp) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(resp);
|
|
||||||
},
|
|
||||||
reject: (err) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(err);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.send({ method, id, params });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
respond(id: number, result: Record<string, unknown>): void {
|
|
||||||
this.proc.stdin!.write(JSON.stringify({ id, result }) + '\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
|
||||||
const resp = await this.request('initialize', {
|
|
||||||
clientInfo: { name: 'nanoclaw', title: 'NanoClaw Codex', version: '1.0' },
|
|
||||||
capabilities: { experimentalApi: false },
|
|
||||||
}, 15_000);
|
|
||||||
|
|
||||||
if (resp.error) {
|
|
||||||
throw new Error(`Initialize failed: ${resp.error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send initialized notification
|
|
||||||
this.send({ method: 'initialized' });
|
|
||||||
log('App-server initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
async startThread(): Promise<string> {
|
|
||||||
const params: Record<string, unknown> = {
|
|
||||||
cwd: EFFECTIVE_CWD,
|
|
||||||
approvalPolicy: 'never',
|
|
||||||
sandbox: 'danger-full-access',
|
|
||||||
experimentalRawEvents: false,
|
|
||||||
persistExtendedHistory: false,
|
|
||||||
};
|
|
||||||
if (CODEX_MODEL) params.model = CODEX_MODEL;
|
|
||||||
|
|
||||||
const resp = await this.request('thread/start', params, 30_000);
|
|
||||||
if (resp.error) {
|
|
||||||
throw new Error(`thread/start failed: ${resp.error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const thread = resp.result?.thread as Record<string, unknown> | undefined;
|
|
||||||
const threadId = thread?.id as string;
|
|
||||||
log(`Thread started: ${threadId}`);
|
|
||||||
return threadId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async resumeThread(threadId: string): Promise<string> {
|
|
||||||
const params: Record<string, unknown> = {
|
|
||||||
threadId,
|
|
||||||
cwd: EFFECTIVE_CWD,
|
|
||||||
approvalPolicy: 'never',
|
|
||||||
sandbox: 'danger-full-access',
|
|
||||||
persistExtendedHistory: false,
|
|
||||||
};
|
|
||||||
if (CODEX_MODEL) params.model = CODEX_MODEL;
|
|
||||||
|
|
||||||
const resp = await this.request('thread/resume', params, 30_000);
|
|
||||||
if (resp.error) {
|
|
||||||
// If resume fails (e.g., thread not found), start a new one
|
|
||||||
log(`thread/resume failed: ${resp.error.message}, starting new thread`);
|
|
||||||
return this.startThread();
|
|
||||||
}
|
|
||||||
|
|
||||||
const thread = resp.result?.thread as Record<string, unknown> | undefined;
|
|
||||||
const actualId = (thread?.id as string) || threadId;
|
|
||||||
log(`Thread resumed: ${actualId}`);
|
|
||||||
return actualId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async startTurn(threadId: string, text: string): Promise<string> {
|
|
||||||
// Parse [Image: /absolute/path] patterns and convert to multimodal input
|
|
||||||
const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g;
|
|
||||||
const input: Array<Record<string, unknown>> = [];
|
|
||||||
const imagePaths: string[] = [];
|
|
||||||
let match;
|
|
||||||
while ((match = imagePattern.exec(text)) !== null) {
|
|
||||||
imagePaths.push(match[1].trim());
|
|
||||||
}
|
|
||||||
// Add text (with image tags stripped) as first input block
|
|
||||||
const cleanText = text.replace(imagePattern, '').trim();
|
|
||||||
if (cleanText) {
|
|
||||||
input.push({ type: 'text', text: cleanText, text_elements: [] });
|
|
||||||
}
|
|
||||||
// Add image input blocks
|
|
||||||
for (const imgPath of imagePaths) {
|
|
||||||
if (fs.existsSync(imgPath)) {
|
|
||||||
input.push({ type: 'localImage', path: imgPath });
|
|
||||||
log(`Adding image input: ${imgPath}`);
|
|
||||||
} else {
|
|
||||||
log(`Image not found, skipping: ${imgPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (input.length === 0) {
|
|
||||||
input.push({ type: 'text', text, text_elements: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
const params: Record<string, unknown> = {
|
|
||||||
threadId,
|
|
||||||
input,
|
|
||||||
};
|
|
||||||
if (CODEX_EFFORT) params.effort = CODEX_EFFORT;
|
|
||||||
|
|
||||||
const resp = await this.request('turn/start', params, 30_000);
|
|
||||||
if (resp.error) {
|
|
||||||
throw new Error(`turn/start failed: ${resp.error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const turn = resp.result?.turn as Record<string, unknown> | undefined;
|
|
||||||
const turnId = turn?.id as string;
|
|
||||||
log(`Turn started: ${turnId}`);
|
|
||||||
return turnId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async steerTurn(threadId: string, turnId: string, text: string): Promise<void> {
|
|
||||||
const resp = await this.request('turn/steer', {
|
|
||||||
threadId,
|
|
||||||
input: [{ type: 'text', text, text_elements: [] }],
|
|
||||||
expectedTurnId: turnId,
|
|
||||||
}, 10_000);
|
|
||||||
|
|
||||||
if (resp.error) {
|
|
||||||
log(`turn/steer failed: ${resp.error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async interruptTurn(threadId: string, turnId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.request('turn/interrupt', { threadId, turnId }, 10_000);
|
|
||||||
} catch (err) {
|
|
||||||
log(`turn/interrupt failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kill(): void {
|
|
||||||
try {
|
|
||||||
this.proc.kill('SIGTERM');
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!this.proc.killed) this.proc.kill('SIGKILL');
|
|
||||||
}, 5000);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
get alive(): boolean {
|
|
||||||
return !this.proc.killed && this.proc.exitCode === null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Turn Execution ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a turn and collect the agent's text response.
|
* Parse [Image: /path] patterns from text and build SDK input.
|
||||||
* While the turn is running, polls IPC for new messages and injects
|
|
||||||
* them via turn/steer (mid-execution message injection).
|
|
||||||
* Returns when turn/completed notification is received.
|
|
||||||
*/
|
*/
|
||||||
async function executeTurn(
|
function parseInput(text: string): string | UserInput[] {
|
||||||
server: CodexAppServer,
|
const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g;
|
||||||
threadId: string,
|
const imagePaths: string[] = [];
|
||||||
prompt: string,
|
let match;
|
||||||
): Promise<{ result: string; error?: string; turnId: string }> {
|
while ((match = imagePattern.exec(text)) !== null) {
|
||||||
return new Promise((resolve) => {
|
imagePaths.push(match[1].trim());
|
||||||
let agentText = '';
|
}
|
||||||
let turnId = '';
|
|
||||||
let resolved = false;
|
|
||||||
let ipcPollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const cleanup = () => {
|
if (imagePaths.length === 0) return text;
|
||||||
server.setNotificationHandler(null);
|
|
||||||
server.setServerRequestHandler(null);
|
|
||||||
if (ipcPollTimer) {
|
|
||||||
clearTimeout(ipcPollTimer);
|
|
||||||
ipcPollTimer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Timeout safety (5 minutes per turn)
|
const input: UserInput[] = [];
|
||||||
const timer = setTimeout(() => {
|
const cleanText = text.replace(imagePattern, '').trim();
|
||||||
if (!resolved) {
|
if (cleanText) {
|
||||||
resolved = true;
|
input.push({ type: 'text', text: cleanText });
|
||||||
cleanup();
|
}
|
||||||
log('Turn execution timed out (5min)');
|
for (const imgPath of imagePaths) {
|
||||||
resolve({
|
if (fs.existsSync(imgPath)) {
|
||||||
result: agentText || '',
|
input.push({ type: 'local_image', path: imgPath });
|
||||||
error: 'Turn timed out after 5 minutes',
|
log(`Adding image input: ${imgPath}`);
|
||||||
turnId,
|
} else {
|
||||||
});
|
log(`Image not found, skipping: ${imgPath}`);
|
||||||
}
|
}
|
||||||
}, 5 * 60 * 1000);
|
}
|
||||||
|
return input.length > 0 ? input : text;
|
||||||
// IPC polling during turn — steer messages into the running turn
|
|
||||||
const pollIpcDuringTurn = () => {
|
|
||||||
if (resolved) return;
|
|
||||||
|
|
||||||
// Check close sentinel
|
|
||||||
if (shouldClose()) {
|
|
||||||
log('Close sentinel during turn, interrupting');
|
|
||||||
if (turnId) {
|
|
||||||
server.interruptTurn(threadId, turnId).catch(() => {});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for new messages to steer
|
|
||||||
const messages = drainIpcInput();
|
|
||||||
if (messages.length > 0 && turnId) {
|
|
||||||
const text = messages.join('\n');
|
|
||||||
log(`Steering message into turn (${text.length} chars)`);
|
|
||||||
server.steerTurn(threadId, turnId, text).catch((err) => {
|
|
||||||
log(`Steer failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcPollTimer = setTimeout(pollIpcDuringTurn, IPC_POLL_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle notifications (streaming events)
|
|
||||||
server.setNotificationHandler((msg) => {
|
|
||||||
if (resolved) return;
|
|
||||||
|
|
||||||
switch (msg.method) {
|
|
||||||
case 'item/agentMessage/delta': {
|
|
||||||
const delta = (msg.params as Record<string, unknown>)?.delta as string;
|
|
||||||
if (delta) agentText += delta;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'item/completed': {
|
|
||||||
const item = (msg.params as Record<string, unknown>)?.item as Record<string, unknown>;
|
|
||||||
if (item?.type === 'agentMessage') {
|
|
||||||
// Use authoritative text from completed item
|
|
||||||
const text = item.text as string;
|
|
||||||
if (text) agentText = text;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'turn/completed': {
|
|
||||||
const turn = (msg.params as Record<string, unknown>)?.turn as Record<string, unknown>;
|
|
||||||
const status = turn?.status as string;
|
|
||||||
const error = turn?.error as Record<string, unknown> | null;
|
|
||||||
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolved = true;
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
if (status === 'failed') {
|
|
||||||
const errMsg = (error?.message as string) || 'Turn failed';
|
|
||||||
resolve({ result: agentText || '', error: errMsg, turnId });
|
|
||||||
} else {
|
|
||||||
resolve({ result: agentText, turnId });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'turn/started': {
|
|
||||||
const turn = (msg.params as Record<string, unknown>)?.turn as Record<string, unknown>;
|
|
||||||
if (turn?.id) turnId = turn.id as string;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle server requests (approval auto-accept)
|
|
||||||
server.setServerRequestHandler((msg) => {
|
|
||||||
if (msg.id === undefined) return;
|
|
||||||
|
|
||||||
if (msg.method === 'item/commandExecution/requestApproval' ||
|
|
||||||
msg.method === 'item/fileChange/requestApproval' ||
|
|
||||||
msg.method === 'item/permissions/requestApproval') {
|
|
||||||
server.respond(msg.id, { decision: 'accept' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown server request — accept generically
|
|
||||||
server.respond(msg.id, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start IPC polling for mid-turn message injection
|
|
||||||
ipcPollTimer = setTimeout(pollIpcDuringTurn, IPC_POLL_MS);
|
|
||||||
|
|
||||||
// Start the turn
|
|
||||||
server.startTurn(threadId, prompt)
|
|
||||||
.then((id) => { turnId = id; })
|
|
||||||
.catch((err) => {
|
|
||||||
if (!resolved) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolved = true;
|
|
||||||
cleanup();
|
|
||||||
resolve({
|
|
||||||
result: '',
|
|
||||||
error: `Failed to start turn: ${err.message}`,
|
|
||||||
turnId: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main ───────────────────────────────────────────────────────────
|
// ── Turn Execution ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a single turn using the SDK. The SDK wraps `codex exec`,
|
||||||
|
* ensuring the agent completes all work before returning.
|
||||||
|
*/
|
||||||
|
async function executeTurn(
|
||||||
|
thread: Thread,
|
||||||
|
input: string | UserInput[],
|
||||||
|
): Promise<{ result: string; error?: string }> {
|
||||||
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
// Poll close sentinel during turn
|
||||||
|
const sentinel = setInterval(() => {
|
||||||
|
if (shouldClose()) {
|
||||||
|
log('Close sentinel detected during turn, aborting');
|
||||||
|
ac.abort();
|
||||||
|
}
|
||||||
|
}, IPC_POLL_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const turn = await thread.run(input, { signal: ac.signal });
|
||||||
|
return { result: turn.finalResponse };
|
||||||
|
} catch (err) {
|
||||||
|
if (ac.signal.aborted) {
|
||||||
|
return { result: '' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
result: '',
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearInterval(sentinel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
let containerInput: ContainerInput;
|
let containerInput: ContainerInput;
|
||||||
@@ -583,19 +230,35 @@ async function main(): Promise<void> {
|
|||||||
prompt += '\n' + pending.join('\n');
|
prompt += '\n' + pending.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn app-server
|
// Build thread options
|
||||||
const server = new CodexAppServer();
|
const threadOptions: ThreadOptions = {
|
||||||
|
workingDirectory: EFFECTIVE_CWD,
|
||||||
|
approvalPolicy: 'never',
|
||||||
|
sandboxMode: 'danger-full-access',
|
||||||
|
networkAccessEnabled: true,
|
||||||
|
webSearchMode: 'live',
|
||||||
|
};
|
||||||
|
if (CODEX_MODEL) threadOptions.model = CODEX_MODEL;
|
||||||
|
if (CODEX_EFFORT) {
|
||||||
|
threadOptions.modelReasoningEffort = CODEX_EFFORT as ThreadOptions['modelReasoningEffort'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SDK instance (inherits env from parent — CODEX_HOME, OPENAI_API_KEY, etc.)
|
||||||
|
const codex = new Codex();
|
||||||
|
|
||||||
|
// Start or resume thread (resume may fail on first run, fallback to new thread)
|
||||||
|
let thread: Thread;
|
||||||
|
if (containerInput.sessionId) {
|
||||||
|
thread = codex.resumeThread(containerInput.sessionId, threadOptions);
|
||||||
|
log(`Thread resuming (session: ${containerInput.sessionId})`);
|
||||||
|
} else {
|
||||||
|
thread = codex.startThread(threadOptions);
|
||||||
|
log('Thread started (new session)');
|
||||||
|
}
|
||||||
|
|
||||||
|
let turnCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await server.initialize();
|
|
||||||
|
|
||||||
// Start or resume thread
|
|
||||||
const threadId = containerInput.sessionId
|
|
||||||
? await server.resumeThread(containerInput.sessionId)
|
|
||||||
: await server.startThread();
|
|
||||||
|
|
||||||
let turnCount = 0;
|
|
||||||
|
|
||||||
// Main turn loop
|
// Main turn loop
|
||||||
while (true) {
|
while (true) {
|
||||||
turnCount++;
|
turnCount++;
|
||||||
@@ -604,19 +267,27 @@ async function main(): Promise<void> {
|
|||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]',
|
result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]',
|
||||||
newSessionId: threadId,
|
newSessionId: thread.id || undefined,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const input = parseInput(prompt);
|
||||||
log(`Starting turn ${turnCount}/${MAX_TURNS}...`);
|
log(`Starting turn ${turnCount}/${MAX_TURNS}...`);
|
||||||
|
|
||||||
const { result, error } = await executeTurn(server, threadId, prompt);
|
let { result, error } = await executeTurn(thread, input);
|
||||||
|
|
||||||
|
// Fallback: if resume failed on first turn, retry with a new thread
|
||||||
|
if (error && turnCount === 1 && containerInput.sessionId) {
|
||||||
|
log(`Resume may have failed, retrying with new thread: ${error}`);
|
||||||
|
thread = codex.startThread(threadOptions);
|
||||||
|
({ result, error } = await executeTurn(thread, input));
|
||||||
|
}
|
||||||
|
|
||||||
// Check close sentinel
|
// Check close sentinel
|
||||||
if (shouldClose()) {
|
if (shouldClose()) {
|
||||||
if (result) {
|
if (result) {
|
||||||
writeOutput({ status: 'success', result, newSessionId: threadId });
|
writeOutput({ status: 'success', result, newSessionId: thread.id || undefined });
|
||||||
}
|
}
|
||||||
log('Close sentinel detected, exiting');
|
log('Close sentinel detected, exiting');
|
||||||
break;
|
break;
|
||||||
@@ -627,14 +298,14 @@ async function main(): Promise<void> {
|
|||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
result: result || null,
|
result: result || null,
|
||||||
newSessionId: threadId,
|
newSessionId: thread.id || undefined,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: result || null,
|
result: result || null,
|
||||||
newSessionId: threadId,
|
newSessionId: thread.id || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,8 +328,6 @@ async function main(): Promise<void> {
|
|||||||
result: null,
|
result: null,
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
server.kill();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
package-lock.json
generated
5
package-lock.json
generated
@@ -1982,6 +1982,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2548,6 +2549,7 @@
|
|||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -2615,6 +2617,7 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -2690,6 +2693,7 @@
|
|||||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.18",
|
"@vitest/expect": "4.0.18",
|
||||||
"@vitest/mocker": "4.0.18",
|
"@vitest/mocker": "4.0.18",
|
||||||
@@ -2811,6 +2815,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ function prepareGroupEnvironment(
|
|||||||
const authSrc = path.join(hostCodexDir, 'auth.json');
|
const authSrc = path.join(hostCodexDir, 'auth.json');
|
||||||
const authDst = path.join(sessionCodexDir, 'auth.json');
|
const authDst = path.join(sessionCodexDir, 'auth.json');
|
||||||
if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst);
|
if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst);
|
||||||
for (const file of ['config.toml', 'config.json', 'instructions.md']) {
|
for (const file of ['config.toml', 'config.json', 'AGENTS.md']) {
|
||||||
const src = path.join(hostCodexDir, file);
|
const src = path.join(hostCodexDir, file);
|
||||||
const dst = path.join(sessionCodexDir, file);
|
const dst = path.join(sessionCodexDir, file);
|
||||||
if (fs.existsSync(src)) {
|
if (fs.existsSync(src)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user