refactor: remove legacy container and non-discord remnants
This commit is contained in:
444
runners/codex-runner/src/app-server-client.ts
Normal file
444
runners/codex-runner/src/app-server-client.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user