@canonmsg/codex-plugin 0.11.11 → 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
+ }
package/dist/host.js CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { setDefaultResultOrder } from 'node:dns';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { spawnSync } from 'node:child_process';
4
5
  import { dirname } from 'node:path';
5
6
  import { parseArgs } from 'node:util';
6
7
  import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, } from '@canonmsg/agent-sdk';
7
- import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
8
+ import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildPlanApprovalRequest, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
8
9
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
9
10
  import { CodexConversationAdapter, } from './adapter.js';
11
+ import { CodexAppServerAdapter } from './app-server-adapter.js';
12
+ import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
10
13
  import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
11
14
  import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
12
15
  import { detectCodexCliVersion } from './codex-cli-version.js';
@@ -64,6 +67,19 @@ let workspaceRoots = [];
64
67
  let workspaceRootMetadata = [];
65
68
  function buildCodexRuntimeDescriptor(input) {
66
69
  const commands = [
70
+ ...(input.supportsPlanMode
71
+ ? [{
72
+ id: 'plan',
73
+ label: 'Plan first',
74
+ description: 'Ask Codex to plan before implementing. Text after /plan becomes the planning prompt.',
75
+ aliases: ['plan'],
76
+ category: 'plan',
77
+ placements: ['composer_slash', 'command_palette'],
78
+ availability: ['always'],
79
+ trailingTextBehavior: 'send_as_prompt',
80
+ dispatch: { kind: 'text_passthrough', template: '/plan {argument}' },
81
+ }]
82
+ : []),
67
83
  {
68
84
  id: 'runtime-status',
69
85
  label: 'Runtime status',
@@ -79,6 +95,8 @@ function buildCodexRuntimeDescriptor(input) {
79
95
  ...RUNTIME_NEW_SESSION_ACTION,
80
96
  primitive: 'session.new',
81
97
  },
98
+ RUNTIME_STOP_ACTION,
99
+ RUNTIME_STOP_AND_DROP_ACTION,
82
100
  ];
83
101
  const descriptor = buildFirstPartyCodingRuntimeDescriptor({
84
102
  clientType: 'codex',
@@ -91,11 +109,6 @@ function buildCodexRuntimeDescriptor(input) {
91
109
  presentation: input.presentation,
92
110
  streamingTextMode: 'snapshot',
93
111
  commands,
94
- actions: [
95
- RUNTIME_STOP_ACTION,
96
- RUNTIME_STOP_AND_DROP_ACTION,
97
- RUNTIME_NEW_SESSION_ACTION,
98
- ],
99
112
  });
100
113
  if (input.models.length > 0) {
101
114
  return descriptor;
@@ -119,18 +132,6 @@ function buildCodexModelOptions(model) {
119
132
  ? [{ value: model.trim(), label: modelOptionLabel(model.trim()) }]
120
133
  : [];
121
134
  }
122
- function normalizeRuntimeTurnState(value) {
123
- const normalizedTurn = normalizeTurnState(value);
124
- if (normalizedTurn) {
125
- return {
126
- state: normalizedTurn.state,
127
- ...(normalizedTurn.openedAt !== undefined ? { openedAt: normalizedTurn.openedAt } : {}),
128
- ...(normalizedTurn.updatedAt !== undefined ? { updatedAt: normalizedTurn.updatedAt } : {}),
129
- ...(normalizedTurn.turnUpdatedAt !== undefined ? { turnUpdatedAt: normalizedTurn.turnUpdatedAt } : {}),
130
- };
131
- }
132
- return null;
133
- }
134
135
  async function publishAgentRuntime(agentId, runtime) {
135
136
  await publishHostAgentRuntime(agentId, 'codex', runtime);
136
137
  }
@@ -249,6 +250,78 @@ function stringArgs(value) {
249
250
  ? value.filter((item) => typeof item === 'string' && item.trim().length > 0)
250
251
  : undefined;
251
252
  }
253
+ function supportsCodexAppServer(codexBin) {
254
+ if (process.env.CANON_CODEX_TRANSPORT === 'exec')
255
+ return false;
256
+ if (process.env.CANON_CODEX_TRANSPORT === 'app-server')
257
+ return true;
258
+ const result = spawnSync(codexBin, ['app-server', '--help'], {
259
+ encoding: 'utf8',
260
+ stdio: ['ignore', 'ignore', 'ignore'],
261
+ });
262
+ return result.status === 0;
263
+ }
264
+ function parsePlanCommand(content) {
265
+ const trimmed = content.trimStart();
266
+ if (!trimmed.startsWith('/plan'))
267
+ return { planMode: false, content };
268
+ const rest = trimmed.replace(/^\/plan(?:\s+)?/i, '').trim();
269
+ return {
270
+ planMode: true,
271
+ content: rest || 'Please inspect the request and propose a plan before making changes.',
272
+ };
273
+ }
274
+ function mapCodexQuestions(value) {
275
+ if (!Array.isArray(value))
276
+ return undefined;
277
+ const questions = value.slice(0, 12).flatMap((entry) => {
278
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
279
+ return [];
280
+ const record = entry;
281
+ const id = typeof record.id === 'string' && record.id.trim() ? record.id.trim().slice(0, 120) : null;
282
+ const question = typeof record.question === 'string' && record.question.trim()
283
+ ? record.question.trim().slice(0, 1000)
284
+ : null;
285
+ if (!id || !/^[A-Za-z0-9_.:-]{1,120}$/.test(id) || !question)
286
+ return [];
287
+ const header = typeof record.header === 'string' && record.header.trim()
288
+ ? record.header.trim().slice(0, 120)
289
+ : undefined;
290
+ const rawOptions = Array.isArray(record.options) ? record.options : [];
291
+ const choices = rawOptions.slice(0, 12).flatMap((option) => {
292
+ if (!option || typeof option !== 'object' || Array.isArray(option))
293
+ return [];
294
+ const optionRecord = option;
295
+ const label = typeof optionRecord.label === 'string' && optionRecord.label.trim()
296
+ ? optionRecord.label.trim().slice(0, 120)
297
+ : null;
298
+ if (!label)
299
+ return [];
300
+ const description = typeof optionRecord.description === 'string' && optionRecord.description.trim()
301
+ ? optionRecord.description.trim().slice(0, 300)
302
+ : undefined;
303
+ return [{ label, value: label, ...(description ? { description } : {}) }];
304
+ });
305
+ return [{
306
+ id,
307
+ question,
308
+ ...(header ? { header } : {}),
309
+ ...(choices.length > 0 ? { choices } : {}),
310
+ ...(choices.length > 0 && record.allowOther !== false ? { allowOther: true } : {}),
311
+ ...(record.allowOther === true || record.isOther === true ? { allowOther: true } : {}),
312
+ ...(record.isSecret === true ? { isSecret: true } : {}),
313
+ ...(record.multiSelect === true ? { multiSelect: true } : {}),
314
+ }];
315
+ });
316
+ return questions.length > 0 ? questions : undefined;
317
+ }
318
+ function isRecord(value) {
319
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
320
+ }
321
+ function readString(record, key) {
322
+ const value = record[key];
323
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
324
+ }
252
325
  export async function main() {
253
326
  setDefaultResultOrder('ipv4first');
254
327
  const { values: args } = parseArgs({
@@ -290,12 +363,14 @@ export async function main() {
290
363
  }
291
364
  const codexBin = typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex';
292
365
  const codexCliStatus = detectCodexCliVersion(codexBin);
366
+ const useAppServer = supportsCodexAppServer(codexBin);
293
367
  if (codexCliStatus.version) {
294
368
  console.error(`[canon-codex] Detected Codex CLI ${codexCliStatus.version} (${codexBin})`);
295
369
  }
296
370
  else {
297
371
  console.error(`[canon-codex] Could not detect Codex CLI version for ${codexBin}: ${codexCliStatus.error ?? 'unknown result'}`);
298
372
  }
373
+ console.error(`[canon-codex] Codex transport: ${useAppServer ? 'app-server' : 'exec --json'}`);
299
374
  const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
300
375
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
301
376
  const client = new CanonClient(apiKey, baseUrl);
@@ -418,14 +493,6 @@ export async function main() {
418
493
  pendingMembershipChanges.delete(conversationId);
419
494
  }
420
495
  }
421
- async function loadSenderRuntimeState(conversationId, senderId) {
422
- try {
423
- return normalizeRuntimeTurnState(await rtdbRead(`/turn-state/${conversationId}/${senderId}`));
424
- }
425
- catch {
426
- return null;
427
- }
428
- }
429
496
  async function loadHydratedInboundContext(input) {
430
497
  const [conversation, page] = await Promise.all([
431
498
  getConversationMeta(input.conversationId),
@@ -523,6 +590,61 @@ export async function main() {
523
590
  function clearStreaming(conversationId) {
524
591
  runtimeState.clearStreaming(conversationId).catch(() => { });
525
592
  }
593
+ function writeCodexStreaming(session, text, status) {
594
+ if (text !== null) {
595
+ session.turnLiveText = text;
596
+ }
597
+ else if (status !== 'thinking' && session.turnLiveText === 'Thinking…') {
598
+ session.turnLiveText = '';
599
+ }
600
+ runtimeState.writeStreaming(session.conversationId, {
601
+ text: session.turnLiveText,
602
+ status,
603
+ messageId: session.currentTurnId ?? undefined,
604
+ turnId: session.currentTurnId,
605
+ blocks: session.turnBlocks,
606
+ }).catch(() => { });
607
+ }
608
+ function upsertTurnBlock(session, block) {
609
+ const now = Date.now();
610
+ const index = session.turnBlocks.findIndex((existing) => existing.id === block.id);
611
+ const existing = index >= 0 ? session.turnBlocks[index] : null;
612
+ const next = {
613
+ ...(existing ?? {
614
+ sequence: session.turnBlocks.length + 1,
615
+ createdAt: now,
616
+ }),
617
+ ...block,
618
+ turnId: session.currentTurnId ?? block.id,
619
+ updatedAt: now,
620
+ };
621
+ session.turnBlocks = index >= 0
622
+ ? [
623
+ ...session.turnBlocks.slice(0, index),
624
+ next,
625
+ ...session.turnBlocks.slice(index + 1),
626
+ ]
627
+ : [...session.turnBlocks, next];
628
+ }
629
+ function completeTurnBlock(session, id, summary) {
630
+ const existing = session.turnBlocks.find((block) => block.id === id);
631
+ if (!existing)
632
+ return;
633
+ upsertTurnBlock(session, {
634
+ id,
635
+ kind: existing.kind,
636
+ status: 'completed',
637
+ title: existing.title,
638
+ text: existing.text,
639
+ summary: summary ?? existing.summary,
640
+ });
641
+ }
642
+ function buildFinalTurnTrail(session) {
643
+ return buildBoundedTurnTrail(session.turnBlocks.map((block) => ({
644
+ ...block,
645
+ turnId: session.currentTurnId ?? block.turnId,
646
+ })));
647
+ }
526
648
  async function handoffFinalMessage(conversationId) {
527
649
  await sleep(FINAL_MESSAGE_HANDOFF_MS);
528
650
  clearStreaming(conversationId);
@@ -555,6 +677,9 @@ export async function main() {
555
677
  return;
556
678
  session.closed = true;
557
679
  stopVisibleWorkSignal(session);
680
+ if ('close' in session.adapter && typeof session.adapter.close === 'function') {
681
+ session.adapter.close();
682
+ }
558
683
  releaseConversationEnvironment(session.environment);
559
684
  clearStreaming(conversationId);
560
685
  runtimeState.clearSessionState(conversationId).catch(() => { });
@@ -638,11 +763,20 @@ export async function main() {
638
763
  throw new ExecutionEnvironmentError(modelGuard, modelGuard);
639
764
  }
640
765
  const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint);
641
- const session = {
642
- conversationId,
643
- cwd: sessionCwd,
644
- environment,
645
- adapter: new CodexConversationAdapter({
766
+ const adapter = useAppServer
767
+ ? new CodexAppServerAdapter({
768
+ cwd: sessionCwd,
769
+ threadId: storedThreadId,
770
+ codexBin,
771
+ model: policy.model ?? null,
772
+ sandbox: policy.sandbox,
773
+ approvalPolicy: policy.approvalPolicy,
774
+ addDirs: args['add-dir'] ?? [],
775
+ configOverrides: args.config ?? [],
776
+ fullAuto: policy.fullAuto,
777
+ bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
778
+ })
779
+ : new CodexConversationAdapter({
646
780
  cwd: sessionCwd,
647
781
  threadId: storedThreadId,
648
782
  codexBin,
@@ -654,7 +788,12 @@ export async function main() {
654
788
  configOverrides: args.config ?? [],
655
789
  fullAuto: policy.fullAuto,
656
790
  bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
657
- }),
791
+ });
792
+ const session = {
793
+ conversationId,
794
+ cwd: sessionCwd,
795
+ environment,
796
+ adapter,
658
797
  queue: [],
659
798
  running: false,
660
799
  state: {
@@ -673,6 +812,8 @@ export async function main() {
673
812
  lastActivity: Date.now(),
674
813
  typingKeepaliveTimer: null,
675
814
  closed: false,
815
+ turnLiveText: '',
816
+ turnBlocks: [],
676
817
  };
677
818
  sessions.set(conversationId, session);
678
819
  await Promise.all([
@@ -697,8 +838,8 @@ export async function main() {
697
838
  pendingSessionCreations.delete(conversationId);
698
839
  }
699
840
  }
700
- function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = []) {
701
- const nextPrompt = { prompt, intent, sourceMessageId, markAccepted, imagePaths, mediaAddDirs };
841
+ function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false) {
842
+ const nextPrompt = { prompt, intent, sourceMessageId, markAccepted, imagePaths, mediaAddDirs, planMode };
702
843
  if (toFront) {
703
844
  session.queue.unshift(nextPrompt);
704
845
  }
@@ -709,8 +850,145 @@ export async function main() {
709
850
  writeTurn(session);
710
851
  void runNextTurn(session);
711
852
  }
853
+ async function waitForRuntimeInputResponse(input) {
854
+ while (Date.now() < input.expiresAt) {
855
+ const response = await client.consumeRuntimeInputResponse({
856
+ conversationId: input.conversationId,
857
+ inputId: input.inputId,
858
+ }).catch(() => null);
859
+ if (response?.status === 'submitted') {
860
+ return { status: 'submitted', value: response.value, answers: response.answers };
861
+ }
862
+ if (response?.status === 'cancelled' || response?.status === 'timeout') {
863
+ return { status: response.status };
864
+ }
865
+ await sleep(1_000);
866
+ }
867
+ const response = await client.consumeRuntimeInputResponse({
868
+ conversationId: input.conversationId,
869
+ inputId: input.inputId,
870
+ }).catch(() => null);
871
+ if (response?.status === 'submitted') {
872
+ return { status: 'submitted', value: response.value, answers: response.answers };
873
+ }
874
+ return { status: 'timeout' };
875
+ }
876
+ async function waitForRuntimeApprovalResponse(input) {
877
+ while (Date.now() < input.expiresAt) {
878
+ const response = await client.consumeRuntimeApprovalResponse({
879
+ conversationId: input.conversationId,
880
+ approvalId: input.approvalId,
881
+ }).catch(() => null);
882
+ if (response?.status === 'allow') {
883
+ return { decision: 'allow', sessionRule: response.sessionRule };
884
+ }
885
+ if (response?.status === 'deny' || response?.status === 'timeout') {
886
+ return { decision: 'deny' };
887
+ }
888
+ await sleep(1_000);
889
+ }
890
+ await client.consumeRuntimeApprovalResponse({
891
+ conversationId: input.conversationId,
892
+ approvalId: input.approvalId,
893
+ }).catch(() => null);
894
+ return { decision: 'deny' };
895
+ }
896
+ async function handleCodexServerRequest(session, request) {
897
+ const requestId = String(request.id);
898
+ const params = request.params;
899
+ const expiresAt = Date.now() + 30 * 60_000;
900
+ if (request.method === 'item/tool/requestUserInput') {
901
+ const paramsInput = isRecord(params.input) ? params.input : null;
902
+ const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
903
+ const questions = mapCodexQuestions(params.questions ?? paramsInput?.questions ?? paramsArguments?.questions);
904
+ const inputId = readString(params, 'itemId') ?? requestId;
905
+ await client.createRuntimeInputRequest({
906
+ conversationId: session.conversationId,
907
+ inputId,
908
+ kind: 'clarify',
909
+ expiresAt,
910
+ responseUserId: ownerId ?? undefined,
911
+ title: 'Codex needs input',
912
+ prompt: questions?.length
913
+ ? 'Codex needs your input to continue.'
914
+ : 'Codex needs input.',
915
+ ...(questions ? { questions } : {}),
916
+ sensitive: Boolean(questions?.some((question) => question.isSecret)),
917
+ native: {
918
+ runtime: 'codex',
919
+ method: request.method,
920
+ requestId,
921
+ turnId: readString(params, 'turnId') ?? session.currentTurnId ?? undefined,
922
+ handles: {
923
+ itemId: readString(params, 'itemId') ?? '',
924
+ threadId: readString(params, 'threadId') ?? '',
925
+ },
926
+ },
927
+ turnId: session.currentTurnId ?? undefined,
928
+ });
929
+ const response = await waitForRuntimeInputResponse({
930
+ conversationId: session.conversationId,
931
+ inputId,
932
+ expiresAt,
933
+ });
934
+ return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
935
+ }
936
+ const mappedApproval = mapCodexAppServerApprovalRequest({
937
+ method: request.method,
938
+ params,
939
+ });
940
+ if (mappedApproval) {
941
+ const approvalId = readString(params, 'approvalId') ?? readString(params, 'itemId') ?? requestId;
942
+ await client.createRuntimeApprovalRequest({
943
+ conversationId: session.conversationId,
944
+ approvalId,
945
+ toolName: mappedApproval.toolName,
946
+ toolSummary: mappedApproval.toolSummary,
947
+ category: mappedApproval.category,
948
+ risk: mappedApproval.risk,
949
+ riskLevel: mappedApproval.riskLevel,
950
+ native: {
951
+ ...mappedApproval.native,
952
+ requestId,
953
+ method: request.method,
954
+ },
955
+ details: mappedApproval.details,
956
+ responseUserId: ownerId ?? undefined,
957
+ allowSessionRule: true,
958
+ expiresAt,
959
+ turnId: session.currentTurnId ?? undefined,
960
+ });
961
+ const response = await waitForRuntimeApprovalResponse({
962
+ conversationId: session.conversationId,
963
+ approvalId,
964
+ expiresAt,
965
+ });
966
+ if (request.method === 'item/permissions/requestApproval') {
967
+ return response.decision === 'allow'
968
+ ? { permissions: isRecord(params.permissions) ? params.permissions : {}, scope: response.sessionRule ? 'session' : 'turn' }
969
+ : { permissions: {}, scope: 'turn' };
970
+ }
971
+ return mapCanonApprovalResultToCodexDecision({
972
+ decision: response.decision,
973
+ ...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
974
+ });
975
+ }
976
+ return {};
977
+ }
712
978
  async function enqueueInboundMessage(input) {
713
979
  knownConversationIds.add(input.conversationId);
980
+ if (isRecord(input.message.metadata)
981
+ && input.message.metadata.type === 'plan_approval_reply'
982
+ && typeof input.message.metadata.decision === 'string') {
983
+ const session = await getOrCreateSession(input.conversationId);
984
+ const feedback = readString(input.message.metadata, 'feedback');
985
+ const decision = input.message.metadata.decision;
986
+ const prompt = decision === 'approve'
987
+ ? 'The plan was approved. Implement the approved plan now.'
988
+ : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
989
+ enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve');
990
+ return;
991
+ }
714
992
  let materialized = [];
715
993
  if (input.message.id) {
716
994
  try {
@@ -723,7 +1001,11 @@ export async function main() {
723
1001
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
724
1002
  }
725
1003
  }
726
- const content = renderInboundContent(input.message, materialized);
1004
+ const renderedContent = renderInboundContent(input.message, materialized);
1005
+ const planCommand = useAppServer
1006
+ ? parsePlanCommand(renderedContent)
1007
+ : { planMode: false, content: renderedContent };
1008
+ const content = planCommand.content;
727
1009
  const hydrated = await loadHydratedInboundContext({
728
1010
  conversationId: input.conversationId,
729
1011
  message: input.message,
@@ -773,7 +1055,6 @@ export async function main() {
773
1055
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
774
1056
  metadata: {
775
1057
  turnSemantics: 'turn_complete',
776
- turnComplete: true,
777
1058
  replyBehavior: 'suppress_auto_reply',
778
1059
  },
779
1060
  }).catch(() => { });
@@ -789,14 +1070,14 @@ export async function main() {
789
1070
  replyContext,
790
1071
  });
791
1072
  if (session.running && deliveryIntent === 'interrupt') {
792
- enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs);
1073
+ enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
793
1074
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
794
1075
  await session.adapter.interrupt().catch(() => { });
795
1076
  clearStreaming(input.conversationId);
796
1077
  client.setTyping(input.conversationId, false).catch(() => { });
797
1078
  return;
798
1079
  }
799
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs);
1080
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
800
1081
  }
801
1082
  async function runNextTurn(session) {
802
1083
  if (session.running || session.closed)
@@ -808,6 +1089,8 @@ export async function main() {
808
1089
  session.state.lastError = undefined;
809
1090
  session.state.state = 'running';
810
1091
  session.currentTurnId = randomUUID();
1092
+ session.turnLiveText = '';
1093
+ session.turnBlocks = [];
811
1094
  session.currentTurnOpenedAt = Date.now();
812
1095
  session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
813
1096
  session.lastAcceptedIntent = nextTurn.intent;
@@ -817,10 +1100,7 @@ export async function main() {
817
1100
  writeState(session);
818
1101
  writeTurn(session);
819
1102
  startVisibleWorkSignal(session);
820
- runtimeState.writeStreaming(session.conversationId, {
821
- text: 'Thinking…',
822
- status: 'thinking',
823
- }).catch(() => { });
1103
+ writeCodexStreaming(session, 'Thinking…', 'thinking');
824
1104
  try {
825
1105
  const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
826
1106
  if (modelGuard) {
@@ -844,10 +1124,31 @@ export async function main() {
844
1124
  writeTurn(session);
845
1125
  stopVisibleWorkSignal(session);
846
1126
  client.setTyping(session.conversationId, false).catch(() => { });
847
- runtimeState.writeStreaming(session.conversationId, {
1127
+ writeCodexStreaming(session, event.text, 'streaming');
1128
+ return;
1129
+ }
1130
+ if (event.type === 'plan.updated') {
1131
+ session.turnState = 'streaming';
1132
+ markTurnProgress(session);
1133
+ writeTurn(session);
1134
+ stopVisibleWorkSignal(session);
1135
+ client.setTyping(session.conversationId, false).catch(() => { });
1136
+ upsertTurnBlock(session, {
1137
+ id: `plan:${session.currentTurnId}`,
1138
+ kind: 'plan',
1139
+ status: 'running',
1140
+ title: 'Plan',
848
1141
  text: event.text,
849
- status: 'streaming',
850
- }).catch(() => { });
1142
+ });
1143
+ writeCodexStreaming(session, event.text, 'streaming');
1144
+ return;
1145
+ }
1146
+ if (event.type === 'waiting') {
1147
+ session.turnState = 'waiting_input';
1148
+ markTurnProgress(session);
1149
+ writeTurn(session);
1150
+ stopVisibleWorkSignal(session);
1151
+ writeCodexStreaming(session, null, 'waiting_input');
851
1152
  return;
852
1153
  }
853
1154
  if (event.type === 'command.started') {
@@ -855,18 +1156,24 @@ export async function main() {
855
1156
  markTurnProgress(session);
856
1157
  writeTurn(session);
857
1158
  startVisibleWorkSignal(session);
858
- runtimeState.writeStreaming(session.conversationId, {
859
- text: summarizeCommand(event.command),
860
- status: 'tool',
861
- }).catch(() => { });
1159
+ upsertTurnBlock(session, {
1160
+ id: `command:${session.currentTurnId}`,
1161
+ kind: 'tool',
1162
+ status: 'running',
1163
+ title: summarizeCommand(event.command),
1164
+ summary: 'Command running',
1165
+ });
1166
+ writeCodexStreaming(session, null, 'tool');
862
1167
  return;
863
1168
  }
864
1169
  if (event.type === 'command.completed') {
1170
+ completeTurnBlock(session, `command:${session.currentTurnId}`, 'Command completed');
865
1171
  if (session.turnState === 'tool') {
866
1172
  session.turnState = 'thinking';
867
1173
  markTurnProgress(session);
868
1174
  writeTurn(session);
869
1175
  startVisibleWorkSignal(session);
1176
+ writeCodexStreaming(session, null, 'thinking');
870
1177
  }
871
1178
  return;
872
1179
  }
@@ -881,7 +1188,10 @@ export async function main() {
881
1188
  clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
882
1189
  session.adapter.clearThreadId();
883
1190
  };
884
- const runTurnOnce = () => session.adapter.runTurn(nextTurn.prompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs);
1191
+ const runTurnOnce = () => session.adapter.runTurn(nextTurn.prompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
1192
+ planMode: nextTurn.planMode,
1193
+ onServerRequest: (request) => handleCodexServerRequest(session, request),
1194
+ });
885
1195
  let result = await runTurnOnce();
886
1196
  if (!result.interrupted
887
1197
  && !result.finalMessage
@@ -895,10 +1205,28 @@ export async function main() {
895
1205
  if (result.threadId && !session.resetRequested) {
896
1206
  saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
897
1207
  }
898
- if (!result.interrupted && result.finalMessage) {
1208
+ if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1209
+ const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
1210
+ responseUserId: ownerId ?? undefined,
1211
+ title: 'Codex Plan',
1212
+ body: result.finalMessage,
1213
+ });
1214
+ await client.sendMessage(session.conversationId, planApproval.text, {
1215
+ metadata: {
1216
+ ...planApproval.metadata,
1217
+ turnId: session.currentTurnId,
1218
+ turnSemantics: 'control',
1219
+ replyBehavior: 'suppress_auto_reply',
1220
+ },
1221
+ });
1222
+ await handoffFinalMessage(session.conversationId);
1223
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
1224
+ }
1225
+ else if (!result.interrupted && result.finalMessage) {
899
1226
  if (isRecoverableCodexThreadError(result.errorText)) {
900
1227
  clearStoredThread();
901
1228
  }
1229
+ const turnTrail = buildFinalTurnTrail(session);
902
1230
  await client.sendMessage(session.conversationId, result.finalMessage, {
903
1231
  ...(session.activeSelfContextId
904
1232
  ? { selfContextId: session.activeSelfContextId }
@@ -906,8 +1234,8 @@ export async function main() {
906
1234
  metadata: {
907
1235
  turnId: session.currentTurnId,
908
1236
  turnSemantics: 'turn_complete',
909
- turnComplete: true,
910
1237
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1238
+ ...(turnTrail.length > 0 ? { turnTrail } : {}),
911
1239
  },
912
1240
  });
913
1241
  await handoffFinalMessage(session.conversationId);
@@ -920,6 +1248,7 @@ export async function main() {
920
1248
  if (result.errorText) {
921
1249
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
922
1250
  }
1251
+ const turnTrail = buildFinalTurnTrail(session);
923
1252
  await client.sendMessage(session.conversationId, userVisibleError, {
924
1253
  ...(session.activeSelfContextId
925
1254
  ? { selfContextId: session.activeSelfContextId }
@@ -927,8 +1256,8 @@ export async function main() {
927
1256
  metadata: {
928
1257
  turnId: session.currentTurnId,
929
1258
  turnSemantics: 'turn_complete',
930
- turnComplete: true,
931
1259
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1260
+ ...(turnTrail.length > 0 ? { turnTrail } : {}),
932
1261
  },
933
1262
  });
934
1263
  await handoffFinalMessage(session.conversationId);
@@ -958,7 +1287,6 @@ export async function main() {
958
1287
  metadata: {
959
1288
  turnId: session.currentTurnId,
960
1289
  turnSemantics: 'turn_complete',
961
- turnComplete: true,
962
1290
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
963
1291
  },
964
1292
  }).catch(() => { });
@@ -1018,6 +1346,7 @@ export async function main() {
1018
1346
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
1019
1347
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1020
1348
  presentation: runtimePresentation,
1349
+ supportsPlanMode: useAppServer,
1021
1350
  }),
1022
1351
  };
1023
1352
  async function baselineControlSignal(conversationId) {
@@ -1102,12 +1431,14 @@ export async function main() {
1102
1431
  {
1103
1432
  id: 'transport',
1104
1433
  label: 'Transport',
1105
- value: 'exec --json',
1434
+ value: useAppServer ? 'app-server' : 'exec --json',
1106
1435
  },
1107
1436
  {
1108
1437
  id: 'streaming',
1109
1438
  label: 'Live output',
1110
- value: 'Thinking, tools, and completed-message previews',
1439
+ value: useAppServer
1440
+ ? 'Plans, questions, approvals, tools, and message deltas'
1441
+ : 'Thinking, tools, and completed-message previews',
1111
1442
  },
1112
1443
  {
1113
1444
  id: 'codex-cli',
@@ -1118,8 +1449,8 @@ export async function main() {
1118
1449
  {
1119
1450
  id: 'nativeActions',
1120
1451
  label: 'Native actions',
1121
- value: 'Limited until app-server transport',
1122
- tone: 'warning',
1452
+ value: useAppServer ? 'Enabled' : 'Limited until app-server transport',
1453
+ ...(useAppServer ? {} : { tone: 'warning' }),
1123
1454
  },
1124
1455
  ],
1125
1456
  execution: {
@@ -1133,8 +1464,9 @@ export async function main() {
1133
1464
  fallbackReason: resolveExecutionFallbackReason(session?.environment),
1134
1465
  },
1135
1466
  notes: [
1136
- 'This Codex host uses the current exec --json transport, so Canon can show thinking, tool activity, and completed assistant-message previews, but not token-by-token text deltas.',
1137
- 'Codex review, compact/rollback, live plan/diff/reasoning updates, PTY command execution, plugin/app/MCP inventory, and structured approvals require the future app-server transport.',
1467
+ useAppServer
1468
+ ? 'This Codex host uses the app-server transport, so Canon can route native plan mode, runtime questions, approvals, and live turn updates.'
1469
+ : 'This Codex host uses the current exec --json transport, so Canon can show thinking, tool activity, and completed assistant-message previews, but not native plan questions or structured approvals.',
1138
1470
  ],
1139
1471
  };
1140
1472
  await runtimeState.writeRuntimeInfo(conversationId, payload);
@@ -1209,6 +1541,7 @@ export async function main() {
1209
1541
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
1210
1542
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1211
1543
  presentation: runtimePresentation,
1544
+ supportsPlanMode: useAppServer,
1212
1545
  }),
1213
1546
  };
1214
1547
  }
@@ -1229,6 +1562,7 @@ export async function main() {
1229
1562
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
1230
1563
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1231
1564
  presentation: runtimePresentation,
1565
+ supportsPlanMode: useAppServer,
1232
1566
  }),
1233
1567
  };
1234
1568
  }
@@ -1258,13 +1592,9 @@ export async function main() {
1258
1592
  ? inboundMessages.slice(cursorIndex + 1)
1259
1593
  : inboundMessages.slice(-1);
1260
1594
  for (const latestMessage of messagesToRecover) {
1261
- const senderTurnState = latestMessage.senderType === 'ai_agent'
1262
- ? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
1263
- : null;
1264
1595
  const triggerDecision = shouldTriggerAgentTurn({
1265
1596
  senderType: latestMessage.senderType ?? 'human',
1266
1597
  metadata: latestMessage.metadata,
1267
- senderTurnState,
1268
1598
  });
1269
1599
  if (!triggerDecision.allow) {
1270
1600
  console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.11.11",
3
+ "version": "0.12.0",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,8 +29,8 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@canonmsg/agent-sdk": "^1.6.1",
33
- "@canonmsg/core": "^0.23.0"
32
+ "@canonmsg/agent-sdk": "^2.0.0",
33
+ "@canonmsg/core": "^1.0.0"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=18.0.0"