refactor: remove legacy container and non-discord remnants

This commit is contained in:
Eyejoker
2026-03-20 01:07:46 +09:00
parent ea09560128
commit bb0628e8f4
82 changed files with 2712 additions and 10523 deletions

184
runners/codex-runner/package-lock.json generated Normal file
View File

@@ -0,0 +1,184 @@
{
"name": "nanoclaw-codex-runner",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw-codex-runner",
"version": "1.0.0",
"dependencies": {
"@openai/codex-sdk": "^0.115.0"
},
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3"
}
},
"node_modules/@openai/codex": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0.tgz",
"integrity": "sha512-uu689DHUzvuPcb39hJ+7fqy++TAvY32w9VggDpcz3HS0Sx0WadWoAPPcMK547P2T6AqfMsAtA8kspkR3tqErOg==",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.115.0-darwin-arm64",
"@openai/codex-darwin-x64": "npm:@openai/codex@0.115.0-darwin-x64",
"@openai/codex-linux-arm64": "npm:@openai/codex@0.115.0-linux-arm64",
"@openai/codex-linux-x64": "npm:@openai/codex@0.115.0-linux-x64",
"@openai/codex-win32-arm64": "npm:@openai/codex@0.115.0-win32-arm64",
"@openai/codex-win32-x64": "npm:@openai/codex@0.115.0-win32-x64"
}
},
"node_modules/@openai/codex-darwin-arm64": {
"name": "@openai/codex",
"version": "0.115.0-darwin-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-darwin-arm64.tgz",
"integrity": "sha512-wTWV+YlDTL0y0mL+FMmbzhilm+dPkbIZTe/lH3LwBzcEMhgSGLsPdZLfAiMND4wFE21HS7H0pjMogNQEMLQmqg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-darwin-x64": {
"name": "@openai/codex",
"version": "0.115.0-darwin-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-darwin-x64.tgz",
"integrity": "sha512-EPzgymU4CFp83qjv29wXFwhWib3zC8g6SLTJGh2OpcJiOYyLY0bO53FB+QchL1jC9gm1uLyD5j5F3ddI0AbjIg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-linux-arm64": {
"name": "@openai/codex",
"version": "0.115.0-linux-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-linux-arm64.tgz",
"integrity": "sha512-40+SCyI+LvVx/iX30qH7dTQzWt9MZxDaK2E6YRB4hoYej5UALrhkFNzweHa5uvqvhmqGZCuagZOYB8285eGCgw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-linux-x64": {
"name": "@openai/codex",
"version": "0.115.0-linux-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-linux-x64.tgz",
"integrity": "sha512-+6eRd2p4KMrhQvuF7XpjYIdCA2Ok2LbhOq+ywZdLpHbmwQ/Yynd0gJ/Q90xCeo2vloCwl9WsbsiVVSahG5FyWg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-sdk": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.115.0.tgz",
"integrity": "sha512-BPoPhim0uUm3rzugY7JFaFJ+rPG/wMRhvKNFii//Dp3kTb+gFy3jrip7ijXqhswsnqXM3nwTiv7kYHt1+TMUPg==",
"license": "Apache-2.0",
"dependencies": {
"@openai/codex": "0.115.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@openai/codex-win32-arm64": {
"name": "@openai/codex",
"version": "0.115.0-win32-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-win32-arm64.tgz",
"integrity": "sha512-yaYhQ0kPL9Kithmrr1vh5A4c+gqAMhMZeA/htTtkpWW8RQkmwgcYYpX/Ky2cEzu0w51pvxQQy63LgHftc+pg1Q==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-win32-x64": {
"name": "@openai/codex",
"version": "0.115.0-win32-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-win32-x64.tgz",
"integrity": "sha512-wtYf2HJCB+p+Uj7o1W08fRgZMzKCVGRQ4YdSfLRNfF4wwPqX5XsK9blsv7brukn5J9lclYxagiR6qGvbfHbD7w==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -0,0 +1,18 @@
{
"name": "nanoclaw-codex-runner",
"version": "1.0.0",
"type": "module",
"description": "Container-side Codex CLI runner for NanoClaw",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@openai/codex-sdk": "^0.115.0"
},
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,444 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process';
import { createRequire } from 'module';
import path from 'path';
import {
createInitialAppServerTurnState,
getAppServerTurnResult,
isAppServerTurnFinished,
reduceAppServerTurnState,
type AppServerTurnEvent,
type AppServerTurnState,
} from './app-server-state.js';
export interface AppServerInputItemText {
type: 'text';
text: string;
}
export interface AppServerInputItemLocalImage {
type: 'localImage';
path: string;
}
export type AppServerInputItem =
| AppServerInputItemText
| AppServerInputItemLocalImage;
export interface CodexAppServerThreadOptions {
cwd: string;
model?: string;
}
export interface CodexAppServerTurnOptions {
cwd: string;
model?: string;
effort?: string;
}
export interface CodexAppServerTurnResult {
state: AppServerTurnState;
result: string | null;
}
interface JsonRpcResponse {
id: number;
result?: unknown;
error?: {
code?: number;
message?: string;
data?: unknown;
};
}
interface JsonRpcNotification {
method: string;
params?: Record<string, unknown>;
}
interface JsonRpcServerRequest extends JsonRpcNotification {
id: number;
}
interface PendingRequest {
method: string;
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
}
interface ActiveTurn {
threadId: string;
state: AppServerTurnState;
resolve: (value: CodexAppServerTurnResult) => void;
reject: (reason?: unknown) => void;
}
export interface CodexAppServerClientOptions {
cwd: string;
env?: NodeJS.ProcessEnv;
log: (message: string) => void;
}
export class CodexAppServerClient {
private readonly cwd: string;
private readonly env: NodeJS.ProcessEnv;
private readonly log: (message: string) => void;
private readonly pending = new Map<number, PendingRequest>();
private readonly require = createRequire(import.meta.url);
private nextId = 1;
private stdoutBuffer = '';
private activeTurn: ActiveTurn | null = null;
private proc: ChildProcessWithoutNullStreams | null = null;
constructor(options: CodexAppServerClientOptions) {
this.cwd = options.cwd;
this.env = options.env || process.env;
this.log = options.log;
}
async start(): Promise<void> {
if (this.proc) return;
const codexPackagePath = this.require.resolve('@openai/codex/package.json');
const codexBin = path.join(path.dirname(codexPackagePath), 'bin', 'codex.js');
this.proc = spawn(process.execPath, [codexBin, 'app-server'], {
cwd: this.cwd,
env: this.env,
stdio: ['pipe', 'pipe', 'pipe'],
});
this.proc.stdout.setEncoding('utf8');
this.proc.stdout.on('data', (chunk: string) => {
this.stdoutBuffer += chunk;
const lines = this.stdoutBuffer.split('\n');
this.stdoutBuffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
this.handleStdoutLine(trimmed);
}
});
this.proc.stderr.setEncoding('utf8');
this.proc.stderr.on('data', (chunk: string) => {
for (const line of chunk.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
this.log(`[app-server] ${trimmed}`);
}
});
this.proc.on('close', (code) => {
const error = new Error(
`Codex app-server exited with code ${code ?? 'unknown'}`,
);
this.rejectAll(error);
});
this.proc.on('error', (error) => {
this.rejectAll(error);
});
await this.request('initialize', {
clientInfo: {
name: 'nanoclaw_codex_runner',
title: 'NanoClaw Codex Runner',
version: '1.0.0',
},
capabilities: {
experimentalApi: true,
optOutNotificationMethods: [
'item/agentMessage/delta',
'item/plan/delta',
'item/reasoning/textDelta',
'item/reasoning/summaryTextDelta',
'item/reasoning/summaryPartAdded',
],
},
});
this.notify('initialized', {});
}
async close(): Promise<void> {
if (!this.proc) return;
const proc = this.proc;
this.proc = null;
try {
proc.kill('SIGTERM');
} catch {
/* ignore */
}
}
async startOrResumeThread(
sessionId: string | undefined,
options: CodexAppServerThreadOptions,
): Promise<string> {
const params = {
cwd: options.cwd,
model: options.model,
approvalPolicy: 'never',
sandbox: 'danger-full-access',
serviceName: 'nanoclaw',
};
const result = sessionId
? await this.request('thread/resume', {
threadId: sessionId,
...params,
})
: await this.request('thread/start', params);
const thread = (result as { thread?: { id?: string } }).thread;
if (!thread?.id) {
throw new Error('Codex app-server did not return a thread id.');
}
return thread.id;
}
async startTurn(
threadId: string,
input: AppServerInputItem[],
options: CodexAppServerTurnOptions,
): Promise<{
turnId: string;
steer: (nextInput: AppServerInputItem[]) => Promise<void>;
interrupt: () => Promise<void>;
wait: () => Promise<CodexAppServerTurnResult>;
}> {
if (this.activeTurn) {
throw new Error('A Codex app-server turn is already active.');
}
const turnPromise = new Promise<CodexAppServerTurnResult>((resolve, reject) => {
this.activeTurn = {
threadId,
state: createInitialAppServerTurnState(),
resolve,
reject,
};
});
let turnId = '';
try {
const response = (await this.request('turn/start', {
threadId,
input,
cwd: options.cwd,
approvalPolicy: 'never',
sandboxPolicy: {
type: 'dangerFullAccess',
networkAccess: true,
},
model: options.model,
effort: options.effort,
summary: 'concise',
})) as { turn?: { id?: string; status?: string } };
turnId = response.turn?.id || '';
if (!turnId) {
throw new Error('Codex app-server did not return a turn id.');
}
const activeTurn = this.activeTurn as ActiveTurn | null;
if (activeTurn !== null) {
activeTurn.state = reduceAppServerTurnState(activeTurn.state, {
method: 'turn/started',
params: {
turn: {
id: turnId,
status: response.turn?.status || 'inProgress',
},
},
});
}
} catch (error) {
this.activeTurn = null;
throw error;
}
return {
turnId,
steer: async (nextInput) => {
await this.request('turn/steer', {
threadId,
input: nextInput,
expectedTurnId: turnId,
});
},
interrupt: async () => {
await this.request('turn/interrupt', {
threadId,
turnId,
});
},
wait: async () => turnPromise,
};
}
async startCompaction(threadId: string): Promise<CodexAppServerTurnResult> {
if (this.activeTurn) {
throw new Error('A Codex app-server turn is already active.');
}
const turnPromise = new Promise<CodexAppServerTurnResult>((resolve, reject) => {
this.activeTurn = {
threadId,
state: createInitialAppServerTurnState(),
resolve,
reject,
};
});
try {
await this.request('thread/compact/start', { threadId });
} catch (error) {
this.activeTurn = null;
throw error;
}
return turnPromise;
}
private handleStdoutLine(line: string): void {
let message: JsonRpcResponse | JsonRpcNotification | JsonRpcServerRequest;
try {
message = JSON.parse(line);
} catch {
this.log(`[app-server] non-JSON stdout: ${line}`);
return;
}
if (
typeof (message as JsonRpcResponse).id === 'number' &&
('result' in message || 'error' in message) &&
!('method' in message)
) {
this.handleResponse(message as JsonRpcResponse);
return;
}
if (
typeof (message as JsonRpcServerRequest).id === 'number' &&
typeof (message as JsonRpcServerRequest).method === 'string'
) {
this.handleServerRequest(message as JsonRpcServerRequest);
return;
}
if (typeof (message as JsonRpcNotification).method === 'string') {
this.handleNotification(message as JsonRpcNotification);
}
}
private handleResponse(message: JsonRpcResponse): void {
const pending = this.pending.get(message.id);
if (!pending) return;
this.pending.delete(message.id);
if (message.error) {
pending.reject(
new Error(
message.error.message ||
`${pending.method} failed with JSON-RPC error ${message.error.code ?? 'unknown'}`,
),
);
return;
}
pending.resolve(message.result);
}
private handleServerRequest(message: JsonRpcServerRequest): void {
if (message.method.endsWith('/requestApproval')) {
this.respond(message.id, 'acceptForSession');
return;
}
this.respondError(
message.id,
-32601,
`NanoClaw does not handle server request ${message.method}`,
);
}
private handleNotification(message: JsonRpcNotification): void {
if (!this.activeTurn) return;
this.activeTurn.state = reduceAppServerTurnState(
this.activeTurn.state,
message as AppServerTurnEvent,
);
if (!isAppServerTurnFinished(this.activeTurn.state)) {
return;
}
const activeTurn = this.activeTurn;
this.activeTurn = null;
activeTurn.resolve({
state: activeTurn.state,
result: getAppServerTurnResult(activeTurn.state),
});
}
private rejectAll(error: unknown): void {
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
if (this.activeTurn) {
const activeTurn = this.activeTurn;
this.activeTurn = null;
activeTurn.reject(error);
}
}
private request(method: string, params?: unknown): Promise<unknown> {
const id = this.nextId++;
const payload = {
jsonrpc: '2.0',
id,
method,
params,
};
return new Promise((resolve, reject) => {
this.pending.set(id, { method, resolve, reject });
this.write(payload);
});
}
private notify(method: string, params?: unknown): void {
this.write({
jsonrpc: '2.0',
method,
params,
});
}
private respond(id: number, result: unknown): void {
this.write({
jsonrpc: '2.0',
id,
result,
});
}
private respondError(id: number, code: number, message: string): void {
this.write({
jsonrpc: '2.0',
id,
error: { code, message },
});
}
private write(message: Record<string, unknown>): void {
if (!this.proc?.stdin.writable) {
throw new Error('Codex app-server stdin is not writable.');
}
this.proc.stdin.write(JSON.stringify(message) + '\n');
}
}

View File

@@ -0,0 +1,174 @@
export interface AppServerAgentMessageItem {
type: 'agentMessage';
text?: string | null;
phase?: 'commentary' | 'final_answer' | string | null;
}
export interface AppServerContextCompactionItem {
type: 'contextCompaction';
id?: string;
}
export type AppServerItem =
| AppServerAgentMessageItem
| AppServerContextCompactionItem
| {
type: string;
[key: string]: unknown;
};
export interface AppServerTurnState {
turnId?: string;
status: 'pending' | 'inProgress' | 'completed' | 'failed' | 'interrupted';
finalAnswer: string | null;
latestAgentMessage: string | null;
errorMessage: string | null;
compactionCompleted: boolean;
}
export type AppServerTurnEvent =
| {
method: 'turn/started';
params?: { turn?: { id?: string | null; status?: string | null } };
}
| {
method: 'turn/completed';
params?: {
turn?: {
id?: string | null;
status?: string | null;
error?:
| { message?: string | null }
| string
| null;
};
};
}
| {
method: 'item/completed';
params?: { item?: AppServerItem | null };
}
| {
method: 'error';
params?: {
error?: {
message?: string | null;
codexErrorInfo?: {
httpStatusCode?: number | null;
type?: string | null;
} | null;
} | null;
};
};
export function createInitialAppServerTurnState(): AppServerTurnState {
return {
status: 'pending',
finalAnswer: null,
latestAgentMessage: null,
errorMessage: null,
compactionCompleted: false,
};
}
export function reduceAppServerTurnState(
state: AppServerTurnState,
event: AppServerTurnEvent,
): AppServerTurnState {
if (event.method === 'turn/started') {
return {
...state,
turnId: event.params?.turn?.id || state.turnId,
status: 'inProgress',
};
}
if (event.method === 'item/completed') {
const item = event.params?.item;
if (!item) return state;
if (item.type === 'agentMessage') {
const text =
typeof item.text === 'string' && item.text.trim().length > 0
? item.text
: null;
if (!text) return state;
if (item.phase === 'final_answer') {
return {
...state,
finalAnswer: text,
latestAgentMessage: text,
};
}
return {
...state,
latestAgentMessage: text,
};
}
if (item.type === 'contextCompaction') {
return {
...state,
compactionCompleted: true,
};
}
return state;
}
if (event.method === 'error') {
const error = event.params?.error;
const message =
typeof error?.message === 'string' && error.message.trim().length > 0
? error.message.trim()
: 'Codex app-server turn failed.';
const httpStatusCode = error?.codexErrorInfo?.httpStatusCode;
return {
...state,
errorMessage:
typeof httpStatusCode === 'number'
? `${message} (HTTP ${httpStatusCode})`
: message,
};
}
if (event.method === 'turn/completed') {
const turn = event.params?.turn;
const status =
turn?.status === 'completed' ||
turn?.status === 'failed' ||
turn?.status === 'interrupted'
? turn.status
: 'completed';
const turnError =
typeof turn?.error === 'string'
? turn.error
: turn?.error?.message || null;
return {
...state,
turnId: turn?.id || state.turnId,
status,
errorMessage: turnError || state.errorMessage,
};
}
return state;
}
export function isAppServerTurnFinished(state: AppServerTurnState): boolean {
return (
state.status === 'completed' ||
state.status === 'failed' ||
state.status === 'interrupted'
);
}
export function getAppServerTurnResult(
state: AppServerTurnState,
): string | null {
return state.finalAnswer || state.latestAgentMessage;
}

View File

@@ -0,0 +1,631 @@
/**
* NanoClaw Codex Runner
*
* Default runtime is Codex app-server, with SDK fallback available via
* CODEX_RUNTIME=sdk or automatic fallback when app-server startup fails.
*
* Input protocol:
* Stdin: Full ContainerInput JSON (read until EOF)
* IPC: Follow-up messages as JSON files in $NANOCLAW_IPC_DIR/input/
* Sentinel: _close — signals session end
*
* Stdout protocol:
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
*/
import { Codex, type Thread, type ThreadOptions, type UserInput } from '@openai/codex-sdk';
import fs from 'fs';
import path from 'path';
import {
CodexAppServerClient,
type AppServerInputItem,
} from './app-server-client.js';
// ── Types ──────────────────────────────────────────────────────────
interface ContainerInput {
prompt: string;
sessionId?: string;
groupFolder: string;
chatJid: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
agentType?: string;
}
interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
}
// ── Constants ──────────────────────────────────────────────────────
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
const WORK_DIR = process.env.NANOCLAW_WORK_DIR || '';
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
const MAX_TURNS = 100;
const CODEX_RUNTIME = (process.env.CODEX_RUNTIME || 'app-server').toLowerCase();
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
const EFFECTIVE_CWD = WORK_DIR || GROUP_DIR;
const CODEX_MODEL = process.env.CODEX_MODEL || '';
const CODEX_EFFORT = process.env.CODEX_EFFORT || '';
let closeRequested = false;
// ── Helpers ────────────────────────────────────────────────────────
function writeOutput(output: ContainerOutput): void {
console.log(OUTPUT_START_MARKER);
console.log(JSON.stringify(output));
console.log(OUTPUT_END_MARKER);
}
function log(message: string): void {
console.error(`[codex-runner] ${message}`);
}
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk: string) => {
data += chunk;
});
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
function consumeCloseSentinel(): boolean {
if (closeRequested) return true;
if (!fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) return false;
try {
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
} catch {
/* ignore */
}
closeRequested = true;
return true;
}
function drainIpcInput(): string[] {
try {
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
const files = fs
.readdirSync(IPC_INPUT_DIR)
.filter((file) => file.endsWith('.json'))
.sort();
const messages: string[] = [];
for (const file of files) {
const filePath = path.join(IPC_INPUT_DIR, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
fs.unlinkSync(filePath);
if (data.type === 'message' && data.text) {
messages.push(data.text);
}
} catch (err) {
log(
`Failed to process input file ${file}: ${
err instanceof Error ? err.message : String(err)
}`,
);
try {
fs.unlinkSync(filePath);
} catch {
/* ignore */
}
}
}
return messages;
} catch (err) {
log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`);
return [];
}
}
function waitForIpcMessage(): Promise<string | null> {
return new Promise((resolve) => {
const poll = () => {
if (consumeCloseSentinel()) {
resolve(null);
return;
}
const messages = drainIpcInput();
if (messages.length > 0) {
resolve(messages.join('\n'));
return;
}
setTimeout(poll, IPC_POLL_MS);
};
poll();
});
}
function extractImagePaths(text: string): { cleanText: string; imagePaths: string[] } {
const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g;
const imagePaths: string[] = [];
let match: RegExpExecArray | null;
while ((match = imagePattern.exec(text)) !== null) {
imagePaths.push(match[1].trim());
}
return {
cleanText: text.replace(imagePattern, '').trim(),
imagePaths,
};
}
function parseSdkInput(text: string): string | UserInput[] {
const { cleanText, imagePaths } = extractImagePaths(text);
if (imagePaths.length === 0) return text;
const input: UserInput[] = [];
if (cleanText) {
input.push({ type: 'text', text: cleanText });
}
for (const imgPath of imagePaths) {
if (fs.existsSync(imgPath)) {
input.push({ type: 'local_image', path: imgPath });
log(`Adding image input: ${imgPath}`);
} else {
log(`Image not found, skipping: ${imgPath}`);
}
}
return input.length > 0 ? input : text;
}
function parseAppServerInput(text: string): AppServerInputItem[] {
const { cleanText, imagePaths } = extractImagePaths(text);
const input: AppServerInputItem[] = [];
if (cleanText) {
input.push({ type: 'text', text: cleanText });
}
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 });
}
return input;
}
function getThreadOptions(): ThreadOptions {
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'];
}
return threadOptions;
}
async function executeSdkTurn(
thread: Thread,
input: string | UserInput[],
): Promise<{ result: string; error?: string }> {
const ac = new AbortController();
let turnSeconds = 0;
const sentinel = setInterval(() => {
if (consumeCloseSentinel()) {
log('Close sentinel detected during SDK turn, aborting');
ac.abort();
return;
}
turnSeconds += 5;
if (turnSeconds % 60 === 0) {
log(`Turn in progress... (${Math.round(turnSeconds / 60)}min)`);
}
}, 5000);
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);
}
}
async function executeAppServerTurn(
client: CodexAppServerClient,
threadId: string,
prompt: string,
): Promise<{ result: string; error?: string }> {
const activeTurn = await client.startTurn(threadId, parseAppServerInput(prompt), {
cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined,
effort: CODEX_EFFORT || undefined,
});
let elapsedMs = 0;
let polling = true;
const pollDuringTurn = async () => {
if (!polling) return;
if (consumeCloseSentinel()) {
log('Close sentinel detected during app-server turn, interrupting');
polling = false;
try {
await activeTurn.interrupt();
} catch (err) {
log(
`Failed to interrupt active turn: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
return;
}
const messages = drainIpcInput();
if (messages.length > 0) {
const merged = messages.join('\n');
log(`Steering active turn with ${messages.length} queued message(s)`);
try {
await activeTurn.steer(parseAppServerInput(merged));
} catch (err) {
log(
`turn/steer failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
elapsedMs += IPC_POLL_MS;
if (elapsedMs > 0 && elapsedMs % 60000 === 0) {
log(`Turn in progress... (${Math.round(elapsedMs / 60000)}min)`);
}
setTimeout(() => void pollDuringTurn(), IPC_POLL_MS);
};
setTimeout(() => void pollDuringTurn(), IPC_POLL_MS);
try {
const { state, result } = await activeTurn.wait();
if (state.status === 'completed') {
return { result: result || '' };
}
if (state.status === 'interrupted' && consumeCloseSentinel()) {
return { result: result || '' };
}
return {
result: result || '',
error: state.errorMessage || `Codex turn finished with status ${state.status}`,
};
} finally {
polling = false;
}
}
async function runSdkSession(
containerInput: ContainerInput,
prompt: string,
): Promise<void> {
const threadOptions = getThreadOptions();
const codex = new Codex();
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;
while (true) {
turnCount++;
if (turnCount > MAX_TURNS) {
log(`Turn limit reached (${MAX_TURNS}), exiting`);
writeOutput({
status: 'success',
result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]',
newSessionId: thread.id || undefined,
});
break;
}
const input = parseSdkInput(prompt);
log(`Starting SDK turn ${turnCount}/${MAX_TURNS}...`);
let { result, error } = await executeSdkTurn(thread, input);
if (error && turnCount === 1 && containerInput.sessionId) {
log(`Resume may have failed, retrying with new thread: ${error}`);
thread = codex.startThread(threadOptions);
({ result, error } = await executeSdkTurn(thread, input));
}
if (consumeCloseSentinel()) {
if (result) {
writeOutput({
status: 'success',
result,
newSessionId: thread.id || undefined,
});
}
log('Close sentinel detected, exiting SDK runtime');
break;
}
if (error) {
log(`SDK turn error: ${error}`);
writeOutput({
status: 'error',
result: result || null,
newSessionId: thread.id || undefined,
error,
});
} else {
writeOutput({
status: 'success',
result: result || null,
newSessionId: thread.id || undefined,
});
}
log('SDK turn done, waiting for next IPC message...');
const nextMessage = await waitForIpcMessage();
if (nextMessage === null) {
log('Close sentinel received, exiting SDK runtime');
break;
}
log(`Got new SDK message (${nextMessage.length} chars)`);
prompt = nextMessage;
}
}
async function runAppServerCompact(
client: CodexAppServerClient,
threadId: string | undefined,
): Promise<void> {
if (!threadId) {
writeOutput({
status: 'success',
result: '현재 활성 Codex 세션이 없어 compact를 건너뜁니다.',
});
return;
}
const { state } = await client.startCompaction(threadId);
if (state.status === 'failed') {
writeOutput({
status: 'error',
result: null,
newSessionId: threadId,
error: state.errorMessage || 'Conversation compaction failed.',
});
return;
}
writeOutput({
status: 'success',
result: state.compactionCompleted
? 'Conversation compacted.'
: 'Compaction requested but contextCompaction was not observed.',
newSessionId: threadId,
});
}
async function runAppServerSession(
containerInput: ContainerInput,
prompt: string,
): Promise<void> {
const client = new CodexAppServerClient({
cwd: EFFECTIVE_CWD,
env: process.env,
log,
});
await client.start();
let threadId: string | undefined;
try {
try {
threadId = await client.startOrResumeThread(containerInput.sessionId, {
cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined,
});
log(
containerInput.sessionId
? `App-server thread resumed (${threadId})`
: `App-server thread started (${threadId})`,
);
} catch (err) {
if (!containerInput.sessionId) throw err;
log(
`App-server resume failed, retrying with new thread: ${
err instanceof Error ? err.message : String(err)
}`,
);
threadId = await client.startOrResumeThread(undefined, {
cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined,
});
log(`App-server thread restarted (${threadId})`);
}
const trimmedPrompt = prompt.trim();
if (trimmedPrompt === '/compact') {
await runAppServerCompact(client, threadId);
return;
}
let turnCount = 0;
while (true) {
turnCount++;
if (turnCount > MAX_TURNS) {
log(`Turn limit reached (${MAX_TURNS}), exiting`);
writeOutput({
status: 'success',
result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]',
newSessionId: threadId,
});
break;
}
log(`Starting app-server turn ${turnCount}/${MAX_TURNS}...`);
const { result, error } = await executeAppServerTurn(client, threadId, prompt);
if (consumeCloseSentinel()) {
if (result) {
writeOutput({
status: 'success',
result,
newSessionId: threadId,
});
}
log('Close sentinel detected, exiting app-server runtime');
break;
}
if (error) {
log(`App-server turn error: ${error}`);
writeOutput({
status: 'error',
result: result || null,
newSessionId: threadId,
error,
});
} else {
writeOutput({
status: 'success',
result: result || null,
newSessionId: threadId,
});
}
log('App-server turn done, waiting for next IPC message...');
const nextMessage = await waitForIpcMessage();
if (nextMessage === null) {
log('Close sentinel received, exiting app-server runtime');
break;
}
log(`Got new app-server message (${nextMessage.length} chars)`);
prompt = nextMessage;
}
} finally {
await client.close();
}
}
function shouldUseAppServer(): boolean {
return CODEX_RUNTIME !== 'sdk';
}
// ── Main ──────────────────────────────────────────────────────────
async function main(): Promise<void> {
let containerInput: ContainerInput;
try {
const stdinData = await readStdin();
containerInput = JSON.parse(stdinData);
try {
fs.unlinkSync('/tmp/input.json');
} catch {
/* may not exist */
}
log(`Received input for group: ${containerInput.groupFolder}`);
} catch (err) {
writeOutput({
status: 'error',
result: null,
error: `Failed to parse input: ${
err instanceof Error ? err.message : String(err)
}`,
});
process.exit(1);
}
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
closeRequested = false;
try {
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
} catch {
/* ignore */
}
let prompt = containerInput.prompt;
if (containerInput.isScheduledTask) {
prompt = `[SCHEDULED TASK]\n\n${prompt}`;
}
const pending = drainIpcInput();
if (pending.length > 0) {
prompt += '\n' + pending.join('\n');
}
const preferAppServer = shouldUseAppServer();
try {
if (preferAppServer) {
try {
log(`Runtime selected: app-server (${CODEX_RUNTIME})`);
await runAppServerSession(containerInput, prompt);
return;
} catch (err) {
if (CODEX_RUNTIME === 'app-server') {
log(
`App-server runtime failed, falling back to SDK: ${
err instanceof Error ? err.message : String(err)
}`,
);
} else {
throw err;
}
}
}
log('Runtime selected: sdk');
await runSdkSession(containerInput, prompt);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Runner error: ${errorMessage}`);
writeOutput({
status: 'error',
result: null,
error: errorMessage,
});
}
}
main();

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}