@canonmsg/codex-plugin 0.11.12 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/adapter.d.ts CHANGED
@@ -8,6 +8,12 @@ export type CodexEvent = {
8
8
  } | {
9
9
  type: 'message';
10
10
  text: string;
11
+ } | {
12
+ type: 'plan.updated';
13
+ text: string;
14
+ } | {
15
+ type: 'waiting';
16
+ reason: string;
11
17
  } | {
12
18
  type: 'command.started';
13
19
  command: string;
@@ -24,6 +30,15 @@ export type CodexEvent = {
24
30
  output_tokens?: number;
25
31
  };
26
32
  };
33
+ export interface CodexServerRequest {
34
+ id: string | number;
35
+ method: string;
36
+ params: Record<string, unknown>;
37
+ }
38
+ export interface CodexRunTurnOptions {
39
+ planMode?: boolean;
40
+ onServerRequest?: (request: CodexServerRequest) => Promise<unknown>;
41
+ }
27
42
  export interface CodexTurnResult {
28
43
  threadId: string | null;
29
44
  finalMessage: string | null;
@@ -64,7 +79,7 @@ export declare class CodexConversationAdapter {
64
79
  setModel(model: string | null): void;
65
80
  isRunning(): boolean;
66
81
  interrupt(): Promise<void>;
67
- runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void, imagePaths?: readonly string[], extraAddDirs?: readonly string[]): Promise<CodexTurnResult>;
82
+ runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void, imagePaths?: readonly string[], extraAddDirs?: readonly string[], _options?: CodexRunTurnOptions): Promise<CodexTurnResult>;
68
83
  private buildAddDirs;
69
84
  private buildArgs;
70
85
  private canResumeWithCurrentPolicy;
package/dist/adapter.js CHANGED
@@ -51,7 +51,7 @@ export class CodexConversationAdapter {
51
51
  this.child.kill('SIGKILL');
52
52
  }, 5_000);
53
53
  }
54
- async runTurn(prompt, onEvent, onLog, imagePaths = [], extraAddDirs = []) {
54
+ async runTurn(prompt, onEvent, onLog, imagePaths = [], extraAddDirs = [], _options = {}) {
55
55
  if (this.child) {
56
56
  throw new Error('A Codex turn is already in progress for this conversation');
57
57
  }
@@ -0,0 +1,62 @@
1
+ import type { CodexApprovalPolicy, CodexEvent, CodexRunTurnOptions, CodexSandboxMode, CodexTurnResult } from './adapter.js';
2
+ export declare class CodexAppServerAdapter {
3
+ private readonly cwd;
4
+ private readonly codexBin;
5
+ private model;
6
+ private readonly sandbox;
7
+ private readonly legacyApprovalPolicy;
8
+ private readonly addDirs;
9
+ private readonly configOverrides;
10
+ private readonly fullAuto;
11
+ private readonly bypassApprovalsAndSandbox;
12
+ private child;
13
+ private threadId;
14
+ private loadedThreadId;
15
+ private resolvedModel;
16
+ private currentTurnId;
17
+ private requestSeq;
18
+ private pending;
19
+ private currentOnEvent;
20
+ private currentOnLog;
21
+ private currentRequestHandler;
22
+ private currentTurnResolve;
23
+ private currentTurnReject;
24
+ private currentFinalMessage;
25
+ private currentErrorText;
26
+ private interrupted;
27
+ private initialized;
28
+ private messageTextByItem;
29
+ private planText;
30
+ constructor(opts: {
31
+ cwd: string;
32
+ threadId?: string | null;
33
+ codexBin?: string;
34
+ model?: string | null;
35
+ sandbox?: CodexSandboxMode | null;
36
+ approvalPolicy?: CodexApprovalPolicy | null;
37
+ addDirs?: string[];
38
+ configOverrides?: string[];
39
+ fullAuto?: boolean;
40
+ bypassApprovalsAndSandbox?: boolean;
41
+ });
42
+ getThreadId(): string | null;
43
+ clearThreadId(): void;
44
+ setModel(model: string | null): void;
45
+ isRunning(): boolean;
46
+ interrupt(): Promise<void>;
47
+ close(): void;
48
+ runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void, imagePaths?: readonly string[], _extraAddDirs?: readonly string[], options?: CodexRunTurnOptions): Promise<CodexTurnResult>;
49
+ private resolveApprovalPolicy;
50
+ private configPayload;
51
+ private rememberResolvedModel;
52
+ private sandboxPolicyPayload;
53
+ private buildWritableRoots;
54
+ private ensureStarted;
55
+ private handleLine;
56
+ private handleServerRequest;
57
+ private handleNotification;
58
+ private resolveCurrentTurn;
59
+ private clearActiveTurn;
60
+ private sendRequest;
61
+ private write;
62
+ }
@@ -0,0 +1,502 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ export class CodexAppServerAdapter {
4
+ cwd;
5
+ codexBin;
6
+ model;
7
+ sandbox;
8
+ legacyApprovalPolicy;
9
+ addDirs;
10
+ configOverrides;
11
+ fullAuto;
12
+ bypassApprovalsAndSandbox;
13
+ child = null;
14
+ threadId;
15
+ loadedThreadId = null;
16
+ resolvedModel = null;
17
+ currentTurnId = null;
18
+ requestSeq = 1;
19
+ pending = new Map();
20
+ currentOnEvent = null;
21
+ currentOnLog = null;
22
+ currentRequestHandler = null;
23
+ currentTurnResolve = null;
24
+ currentTurnReject = null;
25
+ currentFinalMessage = null;
26
+ currentErrorText = null;
27
+ interrupted = false;
28
+ initialized = false;
29
+ messageTextByItem = new Map();
30
+ planText = '';
31
+ constructor(opts) {
32
+ this.cwd = opts.cwd;
33
+ this.threadId = opts.threadId ?? null;
34
+ this.codexBin = opts.codexBin ?? 'codex';
35
+ this.model = opts.model ?? null;
36
+ this.sandbox = opts.sandbox ?? null;
37
+ this.legacyApprovalPolicy = opts.approvalPolicy ?? null;
38
+ this.addDirs = opts.addDirs ?? [];
39
+ this.configOverrides = opts.configOverrides ?? [];
40
+ this.fullAuto = opts.fullAuto ?? false;
41
+ this.bypassApprovalsAndSandbox = opts.bypassApprovalsAndSandbox ?? false;
42
+ }
43
+ getThreadId() {
44
+ return this.threadId;
45
+ }
46
+ clearThreadId() {
47
+ this.threadId = null;
48
+ this.loadedThreadId = null;
49
+ this.resolvedModel = null;
50
+ }
51
+ setModel(model) {
52
+ this.model = model;
53
+ }
54
+ isRunning() {
55
+ return this.currentTurnId !== null || this.currentTurnResolve !== null;
56
+ }
57
+ async interrupt() {
58
+ if (!this.threadId || !this.currentTurnId)
59
+ return;
60
+ this.interrupted = true;
61
+ await this.sendRequest('turn/interrupt', {
62
+ threadId: this.threadId,
63
+ turnId: this.currentTurnId,
64
+ }).catch(() => { });
65
+ }
66
+ close() {
67
+ this.child?.kill('SIGTERM');
68
+ this.child = null;
69
+ this.initialized = false;
70
+ this.clearActiveTurn();
71
+ }
72
+ async runTurn(prompt, onEvent, onLog, imagePaths = [], _extraAddDirs = [], options = {}) {
73
+ if (this.currentTurnId || this.currentTurnResolve) {
74
+ throw new Error('A Codex turn is already in progress for this conversation');
75
+ }
76
+ await this.ensureStarted();
77
+ this.currentOnEvent = onEvent;
78
+ this.currentOnLog = onLog ?? null;
79
+ this.currentRequestHandler = options.onServerRequest ?? null;
80
+ this.currentFinalMessage = null;
81
+ this.currentErrorText = null;
82
+ this.interrupted = false;
83
+ this.messageTextByItem.clear();
84
+ this.planText = '';
85
+ try {
86
+ if (this.threadId && this.loadedThreadId !== this.threadId) {
87
+ const resumed = await this.sendRequest('thread/resume', {
88
+ threadId: this.threadId,
89
+ cwd: this.cwd,
90
+ ...(this.model ? { model: this.model } : {}),
91
+ ...(this.sandbox ? { sandbox: this.sandbox } : {}),
92
+ ...this.configPayload(),
93
+ approvalPolicy: this.resolveApprovalPolicy(),
94
+ excludeTurns: true,
95
+ persistExtendedHistory: true,
96
+ });
97
+ this.loadedThreadId = this.threadId;
98
+ this.rememberResolvedModel(resumed);
99
+ }
100
+ if (!this.threadId) {
101
+ const started = await this.sendRequest('thread/start', {
102
+ cwd: this.cwd,
103
+ ...(this.model ? { model: this.model } : {}),
104
+ ...(this.sandbox ? { sandbox: this.sandbox } : {}),
105
+ ...this.configPayload(),
106
+ approvalPolicy: this.resolveApprovalPolicy(),
107
+ experimentalRawEvents: false,
108
+ persistExtendedHistory: true,
109
+ });
110
+ const threadId = readString(started.thread, 'id');
111
+ if (!threadId)
112
+ throw new Error('Codex app-server did not return a thread id');
113
+ this.threadId = threadId;
114
+ this.loadedThreadId = threadId;
115
+ this.rememberResolvedModel(started);
116
+ onEvent({ type: 'thread.started', threadId });
117
+ }
118
+ const planModel = this.model ?? this.resolvedModel;
119
+ const turnPromise = new Promise((resolve, reject) => {
120
+ this.currentTurnResolve = resolve;
121
+ this.currentTurnReject = reject;
122
+ });
123
+ turnPromise.catch(() => { });
124
+ const turnStarted = await this.sendRequest('turn/start', {
125
+ threadId: this.threadId,
126
+ input: [
127
+ { type: 'text', text: prompt, text_elements: [] },
128
+ ...imagePaths.map((path) => ({ type: 'localImage', path })),
129
+ ],
130
+ ...(this.model ? { model: this.model } : {}),
131
+ ...this.sandboxPolicyPayload(_extraAddDirs),
132
+ ...(options.planMode
133
+ ? {
134
+ collaborationMode: {
135
+ mode: 'plan',
136
+ settings: {
137
+ ...(planModel ? { model: planModel } : {}),
138
+ reasoning_effort: null,
139
+ developer_instructions: null,
140
+ },
141
+ },
142
+ }
143
+ : {}),
144
+ });
145
+ const turn = turnStarted.turn;
146
+ if (this.currentTurnResolve) {
147
+ this.currentTurnId = readString(turn, 'id') ?? null;
148
+ onEvent({ type: 'turn.started' });
149
+ }
150
+ return await turnPromise;
151
+ }
152
+ catch (error) {
153
+ this.clearActiveTurn();
154
+ throw error;
155
+ }
156
+ }
157
+ resolveApprovalPolicy() {
158
+ if (this.bypassApprovalsAndSandbox || this.fullAuto)
159
+ return 'never';
160
+ return this.legacyApprovalPolicy;
161
+ }
162
+ configPayload() {
163
+ const config = {};
164
+ for (const raw of this.configOverrides) {
165
+ const separator = raw.indexOf('=');
166
+ if (separator <= 0)
167
+ continue;
168
+ const key = raw.slice(0, separator).trim();
169
+ if (!key)
170
+ continue;
171
+ config[key] = parseConfigValue(raw.slice(separator + 1));
172
+ }
173
+ return Object.keys(config).length > 0 ? { config } : {};
174
+ }
175
+ rememberResolvedModel(result) {
176
+ const thread = result.thread;
177
+ this.resolvedModel = readString(thread, 'model') ?? readString(result, 'model') ?? this.resolvedModel;
178
+ }
179
+ sandboxPolicyPayload(extraAddDirs) {
180
+ if (this.bypassApprovalsAndSandbox || this.sandbox === 'danger-full-access') {
181
+ return { sandboxPolicy: { type: 'dangerFullAccess' } };
182
+ }
183
+ if (this.sandbox === 'read-only') {
184
+ return { sandboxPolicy: { type: 'readOnly', networkAccess: true } };
185
+ }
186
+ if (this.sandbox !== 'workspace-write')
187
+ return {};
188
+ const writableRoots = this.buildWritableRoots(extraAddDirs);
189
+ return {
190
+ sandboxPolicy: {
191
+ type: 'workspaceWrite',
192
+ writableRoots,
193
+ networkAccess: true,
194
+ excludeTmpdirEnvVar: false,
195
+ excludeSlashTmp: false,
196
+ },
197
+ };
198
+ }
199
+ buildWritableRoots(extraAddDirs) {
200
+ const seen = new Set();
201
+ const roots = [];
202
+ for (const raw of [this.cwd, ...this.addDirs, ...extraAddDirs]) {
203
+ const value = raw.trim();
204
+ if (!value || seen.has(value))
205
+ continue;
206
+ seen.add(value);
207
+ roots.push(value);
208
+ }
209
+ return roots;
210
+ }
211
+ async ensureStarted() {
212
+ if (this.child && this.initialized)
213
+ return;
214
+ const child = spawn(this.codexBin, ['app-server', '--listen', 'stdio://'], {
215
+ cwd: this.cwd,
216
+ stdio: ['pipe', 'pipe', 'pipe'],
217
+ });
218
+ this.child = child;
219
+ const stdout = createInterface({ input: child.stdout });
220
+ const stderr = createInterface({ input: child.stderr });
221
+ stdout.on('line', (line) => this.handleLine(line));
222
+ stderr.on('line', (line) => {
223
+ const trimmed = line.trim();
224
+ if (trimmed)
225
+ this.currentOnLog?.(trimmed);
226
+ });
227
+ child.on('close', (code) => {
228
+ const message = `Codex app-server exited${code === null ? '' : ` with code ${code}`}`;
229
+ this.initialized = false;
230
+ this.child = null;
231
+ for (const pending of this.pending.values()) {
232
+ pending.reject(new Error(message));
233
+ }
234
+ this.pending.clear();
235
+ if (this.currentTurnReject)
236
+ this.currentTurnReject(new Error(message));
237
+ this.clearActiveTurn();
238
+ });
239
+ child.on('error', (error) => {
240
+ if (this.currentTurnReject)
241
+ this.currentTurnReject(error);
242
+ });
243
+ await this.sendRequest('initialize', {
244
+ clientInfo: { name: 'canon-codex', version: '0.0.0' },
245
+ capabilities: { experimentalApi: true, supportsServerRequests: true },
246
+ });
247
+ this.initialized = true;
248
+ }
249
+ handleLine(line) {
250
+ const message = parseJson(line);
251
+ if (!message)
252
+ return;
253
+ if ('id' in message && ('result' in message || 'error' in message) && !('method' in message)) {
254
+ const id = Number(message.id);
255
+ const pending = this.pending.get(id);
256
+ if (!pending)
257
+ return;
258
+ this.pending.delete(id);
259
+ if (message.error) {
260
+ pending.reject(new Error(stringifyPreview(message.error)));
261
+ }
262
+ else {
263
+ pending.resolve(message.result);
264
+ }
265
+ return;
266
+ }
267
+ if ('id' in message && typeof message.method === 'string') {
268
+ void this.handleServerRequest(message);
269
+ return;
270
+ }
271
+ if (typeof message.method === 'string') {
272
+ this.handleNotification(message.method, (message.params ?? {}));
273
+ }
274
+ }
275
+ async handleServerRequest(request) {
276
+ try {
277
+ const result = this.currentRequestHandler
278
+ ? await this.currentRequestHandler({
279
+ id: request.id,
280
+ method: request.method,
281
+ params: isRecord(request.params) ? request.params : {},
282
+ })
283
+ : defaultServerRequestResult(request.method);
284
+ this.write({ id: request.id, result });
285
+ }
286
+ catch (error) {
287
+ this.write({
288
+ id: request.id,
289
+ error: {
290
+ code: -32000,
291
+ message: error instanceof Error ? error.message : String(error),
292
+ },
293
+ });
294
+ }
295
+ }
296
+ handleNotification(method, params) {
297
+ if (method === 'turn/started') {
298
+ this.currentTurnId = readString(params.turn, 'id') ?? this.currentTurnId;
299
+ this.currentOnEvent?.({ type: 'turn.started' });
300
+ return;
301
+ }
302
+ if (method === 'thread/status/changed') {
303
+ const status = params.status;
304
+ if (status?.type === 'active' && Array.isArray(status.activeFlags)) {
305
+ const flags = status.activeFlags.map(String);
306
+ if (flags.includes('waitingOnApproval') || flags.includes('waitingOnUserInput')) {
307
+ this.currentOnEvent?.({ type: 'waiting', reason: flags.join(', ') });
308
+ }
309
+ }
310
+ return;
311
+ }
312
+ if (method === 'item/agentMessage/delta') {
313
+ const itemId = readString(params, 'itemId') ?? 'agent-message';
314
+ const delta = readString(params, 'delta') ?? '';
315
+ const next = `${this.messageTextByItem.get(itemId) ?? ''}${delta}`;
316
+ this.messageTextByItem.set(itemId, next);
317
+ this.currentFinalMessage = next.trim() || this.currentFinalMessage;
318
+ if (next.trim())
319
+ this.currentOnEvent?.({ type: 'message', text: next });
320
+ return;
321
+ }
322
+ if (method === 'turn/plan/updated') {
323
+ this.planText = renderPlan(params);
324
+ this.currentFinalMessage = this.planText || this.currentFinalMessage;
325
+ if (this.planText)
326
+ this.currentOnEvent?.({ type: 'plan.updated', text: this.planText });
327
+ return;
328
+ }
329
+ if (method === 'item/plan/delta') {
330
+ const delta = readString(params, 'delta') ?? '';
331
+ this.planText = `${this.planText}${delta}`;
332
+ this.currentFinalMessage = this.planText.trim() || this.currentFinalMessage;
333
+ if (this.planText.trim())
334
+ this.currentOnEvent?.({ type: 'plan.updated', text: this.planText });
335
+ return;
336
+ }
337
+ if (method === 'item/started') {
338
+ const item = params.item;
339
+ const summary = summarizeItem(item);
340
+ if (summary)
341
+ this.currentOnEvent?.({ type: 'command.started', command: summary });
342
+ return;
343
+ }
344
+ if (method === 'item/completed') {
345
+ const item = params.item;
346
+ if (item?.type === 'agentMessage') {
347
+ const text = readString(item, 'text');
348
+ if (text) {
349
+ this.currentFinalMessage = text;
350
+ this.currentOnEvent?.({ type: 'message', text });
351
+ }
352
+ }
353
+ else if (item?.type === 'commandExecution') {
354
+ this.currentOnEvent?.({
355
+ type: 'command.completed',
356
+ command: readString(item, 'command') ?? 'Command',
357
+ output: readString(item, 'aggregatedOutput') ?? '',
358
+ exitCode: typeof item.exitCode === 'number' ? item.exitCode : null,
359
+ });
360
+ }
361
+ return;
362
+ }
363
+ if (method === 'turn/completed') {
364
+ const turn = params.turn;
365
+ const status = turn?.status;
366
+ if (isRecord(status) && status.type === 'failed') {
367
+ this.currentErrorText = stringifyPreview(turn?.error);
368
+ }
369
+ this.currentOnEvent?.({ type: 'turn.completed' });
370
+ this.resolveCurrentTurn();
371
+ return;
372
+ }
373
+ if (method === 'error') {
374
+ this.currentErrorText = stringifyPreview(params);
375
+ }
376
+ }
377
+ resolveCurrentTurn() {
378
+ const result = {
379
+ threadId: this.threadId,
380
+ finalMessage: this.currentFinalMessage,
381
+ exitCode: this.currentErrorText ? 1 : 0,
382
+ interrupted: this.interrupted,
383
+ errorText: this.interrupted ? null : this.currentErrorText,
384
+ };
385
+ this.currentTurnResolve?.(result);
386
+ this.clearActiveTurn();
387
+ }
388
+ clearActiveTurn() {
389
+ this.currentTurnId = null;
390
+ this.currentOnEvent = null;
391
+ this.currentOnLog = null;
392
+ this.currentRequestHandler = null;
393
+ this.currentTurnResolve = null;
394
+ this.currentTurnReject = null;
395
+ this.currentFinalMessage = null;
396
+ this.currentErrorText = null;
397
+ this.messageTextByItem.clear();
398
+ this.planText = '';
399
+ }
400
+ sendRequest(method, params) {
401
+ const id = this.requestSeq++;
402
+ return new Promise((resolve, reject) => {
403
+ this.pending.set(id, { resolve, reject });
404
+ try {
405
+ this.write({ id, method, params });
406
+ }
407
+ catch (error) {
408
+ this.pending.delete(id);
409
+ reject(error);
410
+ }
411
+ });
412
+ }
413
+ write(message) {
414
+ if (!this.child)
415
+ throw new Error('Codex app-server is not running');
416
+ this.child.stdin.write(`${JSON.stringify(message)}\n`);
417
+ }
418
+ }
419
+ function parseJson(line) {
420
+ try {
421
+ const parsed = JSON.parse(line);
422
+ return isRecord(parsed) ? parsed : null;
423
+ }
424
+ catch {
425
+ return null;
426
+ }
427
+ }
428
+ function isRecord(value) {
429
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
430
+ }
431
+ function readString(record, key) {
432
+ const value = record?.[key];
433
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
434
+ }
435
+ function stringifyPreview(value) {
436
+ if (typeof value === 'string')
437
+ return value;
438
+ try {
439
+ return JSON.stringify(value ?? {});
440
+ }
441
+ catch {
442
+ return String(value);
443
+ }
444
+ }
445
+ function parseConfigValue(value) {
446
+ const trimmed = value.trim();
447
+ if (!trimmed)
448
+ return '';
449
+ if (trimmed === 'true')
450
+ return true;
451
+ if (trimmed === 'false')
452
+ return false;
453
+ if (trimmed === 'null')
454
+ return null;
455
+ const parsedNumber = Number(trimmed);
456
+ if (Number.isFinite(parsedNumber) && String(parsedNumber) === trimmed)
457
+ return parsedNumber;
458
+ try {
459
+ return JSON.parse(trimmed);
460
+ }
461
+ catch {
462
+ return value;
463
+ }
464
+ }
465
+ function summarizeItem(item) {
466
+ if (!item)
467
+ return null;
468
+ if (item.type === 'commandExecution')
469
+ return readString(item, 'command') ?? 'Running a command';
470
+ if (item.type === 'fileChange')
471
+ return 'Applying file changes';
472
+ if (item.type === 'mcpToolCall')
473
+ return `Calling ${readString(item, 'tool') ?? 'MCP tool'}`;
474
+ if (item.type === 'webSearch')
475
+ return 'Searching the web';
476
+ return null;
477
+ }
478
+ function renderPlan(params) {
479
+ const explanation = readString(params, 'explanation');
480
+ const plan = Array.isArray(params.plan) ? params.plan : [];
481
+ const lines = [
482
+ explanation ? `**Plan**\n${explanation}` : '**Plan**',
483
+ ...plan.flatMap((entry, index) => {
484
+ if (!isRecord(entry))
485
+ return [];
486
+ const step = readString(entry, 'step');
487
+ if (!step)
488
+ return [];
489
+ const status = readString(entry, 'status') ?? 'pending';
490
+ const marker = status === 'completed' ? '[x]' : status === 'inProgress' ? '[~]' : '[ ]';
491
+ return [`${index + 1}. ${marker} ${step}`];
492
+ }),
493
+ ];
494
+ return lines.join('\n');
495
+ }
496
+ function defaultServerRequestResult(method) {
497
+ if (method.includes('requestApproval'))
498
+ return { decision: 'decline' };
499
+ if (method === 'item/tool/requestUserInput')
500
+ return { answers: {} };
501
+ return {};
502
+ }