@canonmsg/codex-plugin 0.19.1 → 0.20.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/README.md CHANGED
@@ -47,9 +47,9 @@ You do not need a git repo for host mode. The plugin passes `--skip-git-repo-che
47
47
  - Interrupt by terminating the active Codex turn
48
48
  - Tool/running status surfaced while Codex is working
49
49
 
50
- ## Current limitation
50
+ ## Transport
51
51
 
52
- The stable `codex exec --json` surface exposes thinking state, tool activity, and completed assistant-message previews, but not token-by-token text deltas. v1 therefore publishes live progress and assistant-message snapshots without claiming true token streaming.
52
+ `canon-codex` drives Codex through the `codex app-server` JSON-RPC transport the only supported transport. Canon routes native plan mode, runtime questions, approvals, tools, and live message deltas. A Codex CLI new enough to provide `codex app-server` is required.
53
53
 
54
54
  Current Canon control truth for Codex host mode:
55
55
 
@@ -1,4 +1,57 @@
1
- import type { CodexApprovalPolicy, CodexEvent, CodexRunTurnOptions, CodexSandboxMode, CodexTurnResult } from './adapter.js';
1
+ export type CodexSandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';
2
+ export type CodexApprovalPolicy = 'untrusted' | 'on-request' | 'never';
3
+ export type CodexEvent = {
4
+ type: 'thread.started';
5
+ threadId: string;
6
+ } | {
7
+ type: 'turn.started';
8
+ } | {
9
+ type: 'message';
10
+ text: string;
11
+ delta?: string;
12
+ itemId?: string;
13
+ } | {
14
+ type: 'plan.updated';
15
+ text: string;
16
+ } | {
17
+ type: 'waiting';
18
+ reason: string;
19
+ } | {
20
+ type: 'command.started';
21
+ command: string;
22
+ itemId?: string;
23
+ } | {
24
+ type: 'command.completed';
25
+ command: string;
26
+ output: string;
27
+ exitCode: number | null;
28
+ itemId?: string;
29
+ } | {
30
+ type: 'turn.completed';
31
+ usage?: {
32
+ input_tokens?: number;
33
+ cached_input_tokens?: number;
34
+ output_tokens?: number;
35
+ };
36
+ } | {
37
+ type: 'skills.changed';
38
+ };
39
+ export interface CodexServerRequest {
40
+ id: string | number;
41
+ method: string;
42
+ params: Record<string, unknown>;
43
+ }
44
+ export interface CodexRunTurnOptions {
45
+ planMode?: boolean;
46
+ onServerRequest?: (request: CodexServerRequest) => Promise<unknown>;
47
+ }
48
+ export interface CodexTurnResult {
49
+ threadId: string | null;
50
+ finalMessage: string | null;
51
+ exitCode: number | null;
52
+ interrupted: boolean;
53
+ errorText: string | null;
54
+ }
2
55
  export type JsonRecord = Record<string, unknown>;
3
56
  export interface CodexSkillMetadata {
4
57
  name: string;
@@ -14,7 +67,6 @@ export declare class CodexAppServerAdapter {
14
67
  private model;
15
68
  private reasoningEffort;
16
69
  private readonly sandbox;
17
- private readonly legacyApprovalPolicy;
18
70
  private readonly addDirs;
19
71
  private readonly configOverrides;
20
72
  private readonly fullAuto;
@@ -39,6 +91,7 @@ export declare class CodexAppServerAdapter {
39
91
  private skillsCache;
40
92
  private messageTextByItem;
41
93
  private planText;
94
+ private traceTurnEpochMs;
42
95
  constructor(opts: {
43
96
  cwd: string;
44
97
  threadId?: string | null;
@@ -46,7 +99,6 @@ export declare class CodexAppServerAdapter {
46
99
  model?: string | null;
47
100
  reasoningEffort?: string | null;
48
101
  sandbox?: CodexSandboxMode | null;
49
- approvalPolicy?: CodexApprovalPolicy | null;
50
102
  addDirs?: string[];
51
103
  configOverrides?: string[];
52
104
  fullAuto?: boolean;
@@ -96,4 +148,6 @@ export declare class CodexAppServerAdapter {
96
148
  private clearActiveTurn;
97
149
  private sendRequest;
98
150
  private write;
151
+ private trace;
152
+ private traceLine;
99
153
  }
@@ -6,7 +6,6 @@ export class CodexAppServerAdapter {
6
6
  model;
7
7
  reasoningEffort;
8
8
  sandbox;
9
- legacyApprovalPolicy;
10
9
  addDirs;
11
10
  configOverrides;
12
11
  fullAuto;
@@ -31,6 +30,7 @@ export class CodexAppServerAdapter {
31
30
  skillsCache = null;
32
31
  messageTextByItem = new Map();
33
32
  planText = '';
33
+ traceTurnEpochMs = null;
34
34
  constructor(opts) {
35
35
  this.cwd = opts.cwd;
36
36
  this.threadId = opts.threadId ?? null;
@@ -38,7 +38,6 @@ export class CodexAppServerAdapter {
38
38
  this.model = opts.model ?? null;
39
39
  this.reasoningEffort = opts.reasoningEffort ?? null;
40
40
  this.sandbox = opts.sandbox ?? null;
41
- this.legacyApprovalPolicy = opts.approvalPolicy ?? null;
42
41
  this.addDirs = opts.addDirs ?? [];
43
42
  this.configOverrides = opts.configOverrides ?? [];
44
43
  this.fullAuto = opts.fullAuto ?? false;
@@ -117,6 +116,8 @@ export class CodexAppServerAdapter {
117
116
  this.interrupted = false;
118
117
  this.messageTextByItem.clear();
119
118
  this.planText = '';
119
+ this.traceTurnEpochMs = Date.now();
120
+ this.trace('turn/run begin');
120
121
  try {
121
122
  if (this.threadId && this.loadedThreadId !== this.threadId) {
122
123
  const resumed = await this.sendRequest('thread/resume', {
@@ -158,9 +159,11 @@ export class CodexAppServerAdapter {
158
159
  this.currentTurnReject = reject;
159
160
  });
160
161
  turnPromise.catch(() => { });
162
+ const turnInput = await this.buildTurnInput(prompt, imagePaths);
163
+ this.trace('turn/start sent');
161
164
  const turnStarted = await this.sendRequest('turn/start', {
162
165
  threadId: this.threadId,
163
- input: await this.buildTurnInput(prompt, imagePaths),
166
+ input: turnInput,
164
167
  ...(this.model ? { model: this.model } : {}),
165
168
  ...this.sandboxPolicyPayload(_extraAddDirs),
166
169
  collaborationMode: {
@@ -172,6 +175,7 @@ export class CodexAppServerAdapter {
172
175
  },
173
176
  },
174
177
  });
178
+ this.trace('turn/start ack');
175
179
  const turn = turnStarted.turn;
176
180
  if (this.currentTurnResolve) {
177
181
  this.currentTurnId = readString(turn, 'id') ?? null;
@@ -187,7 +191,7 @@ export class CodexAppServerAdapter {
187
191
  resolveApprovalPolicy() {
188
192
  if (this.bypassApprovalsAndSandbox || this.fullAuto)
189
193
  return 'never';
190
- return this.legacyApprovalPolicy;
194
+ return null;
191
195
  }
192
196
  configPayload() {
193
197
  const config = {};
@@ -322,6 +326,7 @@ export class CodexAppServerAdapter {
322
326
  const message = parseJson(line);
323
327
  if (!message)
324
328
  return;
329
+ this.traceLine(line, message);
325
330
  if ('id' in message && ('result' in message || 'error' in message) && !('method' in message)) {
326
331
  const id = Number(message.id);
327
332
  const pending = this.pending.get(id);
@@ -395,7 +400,7 @@ export class CodexAppServerAdapter {
395
400
  this.messageTextByItem.set(itemId, next);
396
401
  this.currentFinalMessage = this.joinedAgentMessageText() ?? this.currentFinalMessage;
397
402
  if (next.trim())
398
- this.currentOnEvent?.({ type: 'message', text: next, itemId });
403
+ this.currentOnEvent?.({ type: 'message', text: next, delta, itemId });
399
404
  return;
400
405
  }
401
406
  if (method === 'turn/plan/updated') {
@@ -467,6 +472,7 @@ export class CodexAppServerAdapter {
467
472
  return;
468
473
  }
469
474
  if (method === 'turn/completed') {
475
+ this.trace('turn/completed notification');
470
476
  const turn = params.turn;
471
477
  const status = turn?.status;
472
478
  if (isRecord(status) && status.type === 'failed') {
@@ -530,6 +536,7 @@ export class CodexAppServerAdapter {
530
536
  this.currentErrorText = null;
531
537
  this.messageTextByItem.clear();
532
538
  this.planText = '';
539
+ this.traceTurnEpochMs = null;
533
540
  }
534
541
  sendRequest(method, params) {
535
542
  const id = this.requestSeq++;
@@ -549,6 +556,33 @@ export class CodexAppServerAdapter {
549
556
  throw new Error('Codex app-server is not running');
550
557
  this.child.stdin.write(`${JSON.stringify(message)}\n`);
551
558
  }
559
+ trace(message) {
560
+ if (!isCodexTraceEnabled())
561
+ return;
562
+ const elapsedMs = this.traceTurnEpochMs === null ? 0 : Date.now() - this.traceTurnEpochMs;
563
+ console.error(`[canon-codex-trace] +${elapsedMs}ms ${message}`);
564
+ }
565
+ traceLine(line, message) {
566
+ if (!isCodexTraceEnabled())
567
+ return;
568
+ const method = typeof message.method === 'string' ? message.method : null;
569
+ if (!method) {
570
+ const id = 'id' in message ? ` id=${String(message.id)}` : '';
571
+ this.trace(`line bytes=${line.length} response${id}`);
572
+ return;
573
+ }
574
+ if (method === 'item/agentMessage/delta') {
575
+ const params = isRecord(message.params) ? message.params : {};
576
+ const itemId = readString(params, 'itemId') ?? 'agent-message';
577
+ const delta = readRawString(params, 'delta') ?? '';
578
+ this.trace(`line bytes=${line.length} method=${method} itemId=${itemId} deltaLen=${delta.length}`);
579
+ return;
580
+ }
581
+ this.trace(`line bytes=${line.length} method=${method}`);
582
+ }
583
+ }
584
+ function isCodexTraceEnabled() {
585
+ return process.env.CANON_CODEX_TRACE_EVENTS === '1';
552
586
  }
553
587
  function parseJson(line) {
554
588
  try {
package/dist/host.js CHANGED
@@ -1,7 +1,6 @@
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';
5
4
  import { dirname } from 'node:path';
6
5
  import { parseArgs } from 'node:util';
7
6
  import { getCodexImagePath, inferUploadMimeType, materializeMessageMedia, materializeReplyContextMedia, } from '@canonmsg/agent-sdk';
@@ -16,8 +15,7 @@ import { STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, captureTurnA
16
15
  import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildPlanApprovalRequest, resolveQuestionAllowOther, buildCanonTurnContextV2, buildFirstPartyCodingRuntimeDescriptor, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, EXECUTION_ENVIRONMENT_MODES, DEFAULT_RUNTIME_CAPABILITIES, normalizeTurnMetadata, parseRuntimeCardV1, shouldTriggerAgentTurn, renderCanonHostInboundContent, renderCodingHostInboundPrompt, } from '@canonmsg/core/contract';
17
16
  import { ExecutionEnvironmentError, buildConfiguredWorkspaceOptionsWithRoots, buildLocalRuntimeId, buildPublicWorkspaceOptions, buildPublicWorkspaceRoots, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, prepareConversationEnvironment, releaseConversationEnvironment, resolveCanonAgent, saveRuntimeSessionState, upsertLocalRuntimeEntry, } from '@canonmsg/core/local';
18
17
  import { readHostSessionConfig, resolveHostWorkspaceCwd, } from '@canonmsg/core/host';
19
- import { CodexConversationAdapter, } from './adapter.js';
20
- import { CodexAppServerAdapter } from './app-server-adapter.js';
18
+ import { CodexAppServerAdapter, } from './app-server-adapter.js';
21
19
  import { CODEX_APP_DYNAMIC_TOOLS, deniedCodexAppToolResult, handleCodexAppToolCall, isCodexAppToolCall, } from './codex-app-tools.js';
22
20
  import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
23
21
  import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
@@ -26,7 +24,7 @@ import { detectCodexCliVersion } from './codex-cli-version.js';
26
24
  import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
27
25
  import { attachCodexControlNotifications } from './control-channel.js';
28
26
  import { runCli } from './cli-entry.js';
29
- import { beginCommandBlock, claimCommandBlock, createCommandBlockTracker, hasSpeechSegmentText, textSegmentBlockId, } from './turn-activity.js';
27
+ import { beginCommandBlock, applyCodexMessageToStreamingOutput, claimCommandBlock, createCommandBlockTracker, hasSpeechSegmentText, } from './turn-activity.js';
30
28
  const HELP = `canon-codex — run a local Codex agent host for Canon
31
29
 
32
30
  USAGE
@@ -108,6 +106,35 @@ const CODEX_RUNTIME_CAPABILITIES = {
108
106
  supportsQueue: true,
109
107
  supportsNonFinalPermanentMessages: false,
110
108
  };
109
+ function isCodexTraceEnabled() {
110
+ return process.env.CANON_CODEX_TRACE_EVENTS === '1';
111
+ }
112
+ function createCodexTracePort(port) {
113
+ if (!isCodexTraceEnabled())
114
+ return port;
115
+ return new Proxy(port, {
116
+ get(target, property, receiver) {
117
+ if (property !== 'publishStreaming') {
118
+ return Reflect.get(target, property, receiver);
119
+ }
120
+ return async (input) => {
121
+ const startedAt = Date.now();
122
+ const textLength = typeof input.text === 'string' ? input.text.length : 0;
123
+ console.error(`[canon-codex-trace] publishStreaming sent conversation=${input.conversationId} turn=${input.turnId ?? input.messageId ?? 'unknown'} textLen=${textLength}`);
124
+ try {
125
+ const result = await target.publishStreaming(input);
126
+ console.error(`[canon-codex-trace] publishStreaming ack conversation=${input.conversationId} ackMs=${Date.now() - startedAt}`);
127
+ return result;
128
+ }
129
+ catch (error) {
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ console.error(`[canon-codex-trace] publishStreaming error conversation=${input.conversationId} ackMs=${Date.now() - startedAt} error=${message}`);
132
+ throw error;
133
+ }
134
+ };
135
+ },
136
+ });
137
+ }
111
138
  // This host process resolves and locks exactly one agent profile. The lock
112
139
  // handle returned by resolveCanonAgent is held here so the top-level runCli
113
140
  // error handler (outside main's scope) can release it on a failed start —
@@ -319,9 +346,7 @@ function resolveCodexEffectiveRuntimePolicy(input) {
319
346
  }
320
347
  const approvalOverride = mapCanonPermissionToCodex(permissionMode);
321
348
  const defaultSandbox = (stringArg(input.args, 'sandbox') ?? null);
322
- const defaultApprovalPolicy = (stringArg(input.args, 'ask-for-approval') ?? null);
323
349
  const sandbox = approvalOverride ? approvalOverride.sandbox : defaultSandbox;
324
- const approvalPolicy = approvalOverride ? null : defaultApprovalPolicy;
325
350
  const fullAuto = approvalOverride ? approvalOverride.fullAuto : boolArg(input.args, 'full-auto');
326
351
  const bypassApprovalsAndSandbox = approvalOverride
327
352
  ? approvalOverride.bypassApprovalsAndSandbox
@@ -331,7 +356,6 @@ function resolveCodexEffectiveRuntimePolicy(input) {
331
356
  executionMode: input.environment.mode,
332
357
  permissionMode: permissionMode ?? null,
333
358
  sandbox,
334
- approvalPolicy,
335
359
  fullAuto,
336
360
  bypassApprovalsAndSandbox,
337
361
  });
@@ -339,7 +363,6 @@ function resolveCodexEffectiveRuntimePolicy(input) {
339
363
  ...(model ? { model } : {}),
340
364
  ...(permissionMode ? { permissionMode } : {}),
341
365
  sandbox,
342
- approvalPolicy,
343
366
  fullAuto,
344
367
  bypassApprovalsAndSandbox,
345
368
  fingerprint,
@@ -397,17 +420,6 @@ function stringArgs(value) {
397
420
  ? value.filter((item) => typeof item === 'string' && item.trim().length > 0)
398
421
  : undefined;
399
422
  }
400
- function supportsCodexAppServer(codexBin) {
401
- if (process.env.CANON_CODEX_TRANSPORT === 'exec')
402
- return false;
403
- if (process.env.CANON_CODEX_TRANSPORT === 'app-server')
404
- return true;
405
- const result = spawnSync(codexBin, ['app-server', '--help'], {
406
- encoding: 'utf8',
407
- stdio: ['ignore', 'ignore', 'ignore'],
408
- });
409
- return result.status === 0;
410
- }
411
423
  function parsePlanCommand(content) {
412
424
  const trimmed = content.trimStart();
413
425
  if (!trimmed.startsWith('/plan'))
@@ -483,7 +495,6 @@ export async function main() {
483
495
  model: { type: 'string' },
484
496
  sandbox: { type: 'string' },
485
497
  'ask-for-approval': { type: 'string' },
486
- 'codex-profile': { type: 'string' },
487
498
  'add-dir': { type: 'string', multiple: true },
488
499
  workspace: { type: 'string', multiple: true },
489
500
  'workspace-root': { type: 'string', multiple: true },
@@ -516,14 +527,12 @@ export async function main() {
516
527
  }
517
528
  const codexBin = typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex';
518
529
  const codexCliStatus = detectCodexCliVersion(codexBin);
519
- const useAppServer = supportsCodexAppServer(codexBin);
520
530
  if (codexCliStatus.version) {
521
531
  console.error(`[canon-codex] Detected Codex CLI ${codexCliStatus.version} (${codexBin})`);
522
532
  }
523
533
  else {
524
534
  console.error(`[canon-codex] Could not detect Codex CLI version for ${codexBin}: ${codexCliStatus.error ?? 'unknown result'}`);
525
535
  }
526
- console.error(`[canon-codex] Codex transport: ${useAppServer ? 'app-server' : 'exec --json'}`);
527
536
  const { agentName: profileAgentName, profile, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
528
537
  activeLockHandle = lockHandle ?? null;
529
538
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
@@ -554,7 +563,7 @@ export async function main() {
554
563
  });
555
564
  // The BridgeClient's zod-derived result shapes mirror the core wire types
556
565
  // structurally; the port pins the strong types the host machinery uses.
557
- const port = bridge.client;
566
+ const port = createCodexTracePort(bridge.client);
558
567
  bridge.client.onProtocolError((error) => {
559
568
  console.error(`[canon-codex] Bridge protocol error: ${error.message}`);
560
569
  });
@@ -718,35 +727,19 @@ export async function main() {
718
727
  const initialEffort = config?.effort && CODEX_EFFORT_VALUES.has(config.effort)
719
728
  ? config.effort
720
729
  : null;
721
- const adapter = useAppServer
722
- ? new CodexAppServerAdapter({
723
- cwd: sessionCwd,
724
- threadId: storedThreadId,
725
- codexBin,
726
- model: policy.model ?? null,
727
- reasoningEffort: initialEffort,
728
- sandbox: policy.sandbox,
729
- approvalPolicy: policy.approvalPolicy,
730
- addDirs: args['add-dir'] ?? [],
731
- configOverrides: args.config ?? [],
732
- fullAuto: policy.fullAuto,
733
- bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
734
- dynamicTools: CODEX_APP_DYNAMIC_TOOLS,
735
- })
736
- : new CodexConversationAdapter({
737
- cwd: sessionCwd,
738
- threadId: storedThreadId,
739
- codexBin,
740
- model: policy.model ?? null,
741
- reasoningEffort: initialEffort,
742
- sandbox: policy.sandbox,
743
- approvalPolicy: policy.approvalPolicy,
744
- codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
745
- addDirs: args['add-dir'] ?? [],
746
- configOverrides: args.config ?? [],
747
- fullAuto: policy.fullAuto,
748
- bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
749
- });
730
+ const adapter = new CodexAppServerAdapter({
731
+ cwd: sessionCwd,
732
+ threadId: storedThreadId,
733
+ codexBin,
734
+ model: policy.model ?? null,
735
+ reasoningEffort: initialEffort,
736
+ sandbox: policy.sandbox,
737
+ addDirs: args['add-dir'] ?? [],
738
+ configOverrides: args.config ?? [],
739
+ fullAuto: policy.fullAuto,
740
+ bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
741
+ dynamicTools: CODEX_APP_DYNAMIC_TOOLS,
742
+ });
750
743
  // eslint-disable-next-line prefer-const -- session must be declared before the module closures but assigned after them
751
744
  let session;
752
745
  // ── Per-session RTDB writers (generic skeleton from @canonmsg/agent-host) ──
@@ -976,7 +969,12 @@ export async function main() {
976
969
  session.turnState = 'streaming';
977
970
  writers.writeTurn();
978
971
  writers.stopVisibleWorkSignal();
979
- writers.streamingOutput.replaceTextSegmentSnapshot(textSegmentBlockId(session.currentTurnId, event.itemId), event.text);
972
+ applyCodexMessageToStreamingOutput(writers.streamingOutput, {
973
+ turnId: session.currentTurnId,
974
+ itemId: event.itemId,
975
+ text: event.text,
976
+ delta: event.delta,
977
+ });
980
978
  return;
981
979
  }
982
980
  if (event.type === 'plan.updated') {
@@ -1304,9 +1302,6 @@ export async function main() {
1304
1302
  const params = request.params;
1305
1303
  const expiresAt = Date.now() + 30 * 60_000;
1306
1304
  if (request.method === 'item/tool/call' && isCodexAppToolCall(params)) {
1307
- if (!(session.adapter instanceof CodexAppServerAdapter)) {
1308
- return deniedCodexAppToolResult('codex_app tools require the Codex app-server transport.');
1309
- }
1310
1305
  if (!session.currentTurnCanUseCodexAppTools) {
1311
1306
  return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
1312
1307
  }
@@ -1537,11 +1532,9 @@ export async function main() {
1537
1532
  const renderedContent = renderInboundContent(input.message, materialized);
1538
1533
  const turnMetadata = normalizeTurnMetadata(input.message.metadata);
1539
1534
  const requestedPlanMode = turnMetadata?.requestedTurnMode === 'plan';
1540
- const planCommand = useAppServer
1541
- ? requestedPlanMode
1542
- ? { planMode: true, content: renderedContent }
1543
- : parsePlanCommand(renderedContent)
1544
- : { planMode: false, content: renderedContent };
1535
+ const planCommand = requestedPlanMode
1536
+ ? { planMode: true, content: renderedContent }
1537
+ : parsePlanCommand(renderedContent);
1545
1538
  const content = planCommand.content;
1546
1539
  const hydrated = await loadHydratedInboundContext({
1547
1540
  conversationId: input.conversationId,
@@ -1716,16 +1709,14 @@ export async function main() {
1716
1709
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
1717
1710
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1718
1711
  presentation: runtimePresentation,
1719
- supportsPlanMode: useAppServer,
1720
- supportsCompact: useAppServer,
1721
- supportsRichCards: useAppServer,
1712
+ supportsPlanMode: true,
1713
+ supportsCompact: true,
1714
+ supportsRichCards: true,
1722
1715
  skills: codexSkills,
1723
1716
  }),
1724
1717
  });
1725
1718
  let runtimeDescriptor = buildCurrentRuntimeDescriptor();
1726
1719
  async function refreshCodexSkillInventory(forceReload = false) {
1727
- if (!useAppServer)
1728
- return;
1729
1720
  const probe = new CodexAppServerAdapter({
1730
1721
  cwd: workingDir,
1731
1722
  codexBin,
@@ -1810,10 +1801,6 @@ export async function main() {
1810
1801
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Cannot compact context: no live Codex session`);
1811
1802
  return;
1812
1803
  }
1813
- if (!(session.adapter instanceof CodexAppServerAdapter)) {
1814
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Cannot compact context: compact is only available for Codex app-server sessions`);
1815
- return;
1816
- }
1817
1804
  try {
1818
1805
  await session.adapter.compactThread();
1819
1806
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Compact requested`);
@@ -1896,24 +1883,16 @@ export async function main() {
1896
1883
  const payload = {
1897
1884
  descriptor,
1898
1885
  surfaceMode: 'host',
1899
- // The exec --json transport cannot block on approvals — without a
1900
- // strip-level warning a user can believe they have an approval
1901
- // gate they do not have.
1902
- ...(useAppServer
1903
- ? {}
1904
- : { warning: "Approvals can't block on this Codex CLI — update Codex to enable the app-server transport and approval gates." }),
1905
1886
  statusItems: [
1906
1887
  {
1907
1888
  id: 'transport',
1908
1889
  label: 'Transport',
1909
- value: useAppServer ? 'app-server' : 'exec --json',
1890
+ value: 'app-server',
1910
1891
  },
1911
1892
  {
1912
1893
  id: 'streaming',
1913
1894
  label: 'Live output',
1914
- value: useAppServer
1915
- ? 'Plans, questions, approvals, tools, and message deltas'
1916
- : 'Thinking, tools, and completed-message previews',
1895
+ value: 'Plans, questions, approvals, tools, and message deltas',
1917
1896
  },
1918
1897
  {
1919
1898
  id: 'codex-cli',
@@ -1924,8 +1903,7 @@ export async function main() {
1924
1903
  {
1925
1904
  id: 'nativeActions',
1926
1905
  label: 'Native actions',
1927
- value: useAppServer ? 'Enabled' : 'Limited until app-server transport',
1928
- ...(useAppServer ? {} : { tone: 'warning' }),
1906
+ value: 'Enabled',
1929
1907
  },
1930
1908
  {
1931
1909
  id: 'mediaOut',
@@ -1944,9 +1922,7 @@ export async function main() {
1944
1922
  fallbackReason: resolveExecutionFallbackReason(session?.environment),
1945
1923
  },
1946
1924
  notes: [
1947
- useAppServer
1948
- ? 'This Codex host uses the app-server transport, so Canon can route native plan mode, runtime questions, approvals, and live turn updates.'
1949
- : '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.',
1925
+ 'This Codex host uses the app-server transport, so Canon can route native plan mode, runtime questions, approvals, and live turn updates.',
1950
1926
  ],
1951
1927
  };
1952
1928
  await port.publishRuntimeStatus({
@@ -1,4 +1,4 @@
1
- import type { CodexSandboxMode } from './adapter.js';
1
+ import type { CodexSandboxMode } from './app-server-adapter.js';
2
2
  export declare const CODEX_PERMISSION_OPTIONS: readonly [{
3
3
  readonly value: "readonly";
4
4
  readonly label: "Read-only";
@@ -24,6 +24,16 @@ export declare function createCommandBlockTracker(): CommandBlockTracker;
24
24
  * only owns the codex id scheme.
25
25
  */
26
26
  export declare function textSegmentBlockId(turnId: string | null | undefined, itemId?: string): string;
27
+ export interface CodexTextSegmentOutput {
28
+ appendTextSegmentDelta(id: string, delta: string): void;
29
+ replaceTextSegmentSnapshot(id: string, text: string): void;
30
+ }
31
+ export declare function applyCodexMessageToStreamingOutput(output: CodexTextSegmentOutput, input: {
32
+ turnId: string | null | undefined;
33
+ itemId?: string;
34
+ text: string;
35
+ delta?: string;
36
+ }): void;
27
37
  export declare function beginCommandBlock(tracker: CommandBlockTracker, input: {
28
38
  turnId: string | null | undefined;
29
39
  command: string;
@@ -27,6 +27,14 @@ function normalizeOptionalString(value) {
27
27
  export function textSegmentBlockId(turnId, itemId) {
28
28
  return `message:${turnId ?? 'turn'}:${normalizeOptionalString(itemId) ?? 'latest'}`;
29
29
  }
30
+ export function applyCodexMessageToStreamingOutput(output, input) {
31
+ const segmentId = textSegmentBlockId(input.turnId, input.itemId);
32
+ if (input.delta !== undefined) {
33
+ output.appendTextSegmentDelta(segmentId, input.delta);
34
+ return;
35
+ }
36
+ output.replaceTextSegmentSnapshot(segmentId, input.text);
37
+ }
30
38
  function nextCommandBlockId(tracker, turnId, itemId) {
31
39
  const stableTurnId = turnId ?? 'turn';
32
40
  if (itemId)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,12 +29,12 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@canonmsg/agent-host": "^0.3.0",
33
- "@canonmsg/agent-sdk": "^3.4.3",
34
- "@canonmsg/backend-contracts": "^1.8.1",
35
- "@canonmsg/bridge": "^0.2.1",
36
- "@canonmsg/core": "^3.1.1",
37
- "@canonmsg/framework": "^0.2.1"
32
+ "@canonmsg/agent-host": "^0.5.0",
33
+ "@canonmsg/agent-sdk": "^4.0.0",
34
+ "@canonmsg/backend-contracts": "^1.9.0",
35
+ "@canonmsg/bridge": "^0.3.0",
36
+ "@canonmsg/core": "^3.3.0",
37
+ "@canonmsg/framework": "^0.3.0"
38
38
  },
39
39
  "engines": {
40
40
  "node": ">=18.0.0"
package/dist/adapter.d.ts DELETED
@@ -1,96 +0,0 @@
1
- export type CodexSandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';
2
- export type CodexApprovalPolicy = 'untrusted' | 'on-request' | 'never';
3
- export type CodexEvent = {
4
- type: 'thread.started';
5
- threadId: string;
6
- } | {
7
- type: 'turn.started';
8
- } | {
9
- type: 'message';
10
- text: string;
11
- itemId?: string;
12
- } | {
13
- type: 'plan.updated';
14
- text: string;
15
- } | {
16
- type: 'waiting';
17
- reason: string;
18
- } | {
19
- type: 'command.started';
20
- command: string;
21
- itemId?: string;
22
- } | {
23
- type: 'command.completed';
24
- command: string;
25
- output: string;
26
- exitCode: number | null;
27
- itemId?: string;
28
- } | {
29
- type: 'turn.completed';
30
- usage?: {
31
- input_tokens?: number;
32
- cached_input_tokens?: number;
33
- output_tokens?: number;
34
- };
35
- } | {
36
- type: 'skills.changed';
37
- };
38
- export interface CodexServerRequest {
39
- id: string | number;
40
- method: string;
41
- params: Record<string, unknown>;
42
- }
43
- export interface CodexRunTurnOptions {
44
- planMode?: boolean;
45
- onServerRequest?: (request: CodexServerRequest) => Promise<unknown>;
46
- }
47
- export interface CodexTurnResult {
48
- threadId: string | null;
49
- finalMessage: string | null;
50
- exitCode: number | null;
51
- interrupted: boolean;
52
- errorText: string | null;
53
- }
54
- export declare class CodexConversationAdapter {
55
- private readonly cwd;
56
- private readonly codexBin;
57
- private model;
58
- private reasoningEffort;
59
- private readonly sandbox;
60
- private readonly legacyApprovalPolicy;
61
- private readonly codexProfile;
62
- private readonly addDirs;
63
- private readonly configOverrides;
64
- private readonly fullAuto;
65
- private readonly bypassApprovalsAndSandbox;
66
- private child;
67
- private threadId;
68
- private interruptTimer;
69
- private interrupted;
70
- constructor(opts: {
71
- cwd: string;
72
- threadId?: string | null;
73
- codexBin?: string;
74
- model?: string | null;
75
- reasoningEffort?: string | null;
76
- sandbox?: CodexSandboxMode | null;
77
- approvalPolicy?: CodexApprovalPolicy | null;
78
- codexProfile?: string | null;
79
- addDirs?: string[];
80
- configOverrides?: string[];
81
- fullAuto?: boolean;
82
- bypassApprovalsAndSandbox?: boolean;
83
- });
84
- getThreadId(): string | null;
85
- clearThreadId(): void;
86
- setModel(model: string | null): void;
87
- /** Sets GPT reasoning effort, applied on the next turn via `-c model_reasoning_effort`. */
88
- setReasoningEffort(effort: string | null): void;
89
- isRunning(): boolean;
90
- interrupt(): Promise<void>;
91
- runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void, imagePaths?: readonly string[], extraAddDirs?: readonly string[], _options?: CodexRunTurnOptions): Promise<CodexTurnResult>;
92
- private buildAddDirs;
93
- private buildArgs;
94
- private canResumeWithCurrentPolicy;
95
- private clearActiveProcess;
96
- }
package/dist/adapter.js DELETED
@@ -1,310 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import { createInterface } from 'node:readline';
3
- import { isRecoverableCodexThreadError } from './error-format.js';
4
- export class CodexConversationAdapter {
5
- cwd;
6
- codexBin;
7
- model;
8
- reasoningEffort;
9
- sandbox;
10
- legacyApprovalPolicy;
11
- codexProfile;
12
- addDirs;
13
- configOverrides;
14
- fullAuto;
15
- bypassApprovalsAndSandbox;
16
- child = null;
17
- threadId;
18
- interruptTimer = null;
19
- interrupted = false;
20
- constructor(opts) {
21
- this.cwd = opts.cwd;
22
- this.threadId = opts.threadId ?? null;
23
- this.codexBin = opts.codexBin ?? 'codex';
24
- this.model = opts.model ?? null;
25
- this.reasoningEffort = opts.reasoningEffort ?? null;
26
- this.sandbox = opts.sandbox ?? null;
27
- this.legacyApprovalPolicy = opts.approvalPolicy ?? null;
28
- this.codexProfile = opts.codexProfile ?? null;
29
- this.addDirs = opts.addDirs ?? [];
30
- this.configOverrides = opts.configOverrides ?? [];
31
- this.fullAuto = opts.fullAuto ?? false;
32
- this.bypassApprovalsAndSandbox = opts.bypassApprovalsAndSandbox ?? false;
33
- }
34
- getThreadId() {
35
- return this.threadId;
36
- }
37
- clearThreadId() {
38
- this.threadId = null;
39
- }
40
- setModel(model) {
41
- this.model = model;
42
- }
43
- /** Sets GPT reasoning effort, applied on the next turn via `-c model_reasoning_effort`. */
44
- setReasoningEffort(effort) {
45
- this.reasoningEffort = effort && effort.trim() ? effort.trim() : null;
46
- }
47
- isRunning() {
48
- return this.child !== null;
49
- }
50
- async interrupt() {
51
- if (!this.child)
52
- return;
53
- this.interrupted = true;
54
- this.child.kill('SIGINT');
55
- this.interruptTimer = setTimeout(() => {
56
- if (this.child)
57
- this.child.kill('SIGKILL');
58
- }, 5_000);
59
- }
60
- async runTurn(prompt, onEvent, onLog, imagePaths = [], extraAddDirs = [], _options = {}) {
61
- if (this.child) {
62
- throw new Error('A Codex turn is already in progress for this conversation');
63
- }
64
- const args = this.buildArgs(prompt, imagePaths, extraAddDirs);
65
- const child = spawn(this.codexBin, args, {
66
- cwd: this.cwd,
67
- stdio: ['ignore', 'pipe', 'pipe'],
68
- });
69
- this.child = child;
70
- this.interrupted = false;
71
- let latestMessage = null;
72
- let lastErrorText = null;
73
- const stdout = createInterface({ input: child.stdout });
74
- const stderr = createInterface({ input: child.stderr });
75
- stdout.on('line', (line) => {
76
- const event = parseEventLine(line);
77
- if (!event)
78
- return;
79
- switch (event.type) {
80
- case 'thread.started':
81
- this.threadId = event.thread_id;
82
- onEvent({ type: 'thread.started', threadId: event.thread_id });
83
- break;
84
- case 'turn.started':
85
- onEvent({ type: 'turn.started' });
86
- break;
87
- case 'item.started':
88
- if (event.item?.type === 'command_execution') {
89
- onEvent({
90
- type: 'command.started',
91
- command: String(event.item.command ?? ''),
92
- ...(typeof event.item.id === 'string' && event.item.id.trim()
93
- ? { itemId: event.item.id.trim() }
94
- : {}),
95
- });
96
- }
97
- break;
98
- case 'item.completed':
99
- if (event.item?.type === 'agent_message') {
100
- latestMessage = normalizeMessageText(event.item.text);
101
- if (latestMessage) {
102
- onEvent({
103
- type: 'message',
104
- text: latestMessage,
105
- ...(typeof event.item.id === 'string' && event.item.id.trim()
106
- ? { itemId: event.item.id.trim() }
107
- : {}),
108
- });
109
- }
110
- }
111
- else if (event.item?.type === 'command_execution') {
112
- onEvent({
113
- type: 'command.completed',
114
- command: String(event.item.command ?? ''),
115
- output: String(event.item.aggregated_output ?? ''),
116
- exitCode: typeof event.item.exit_code === 'number' ? event.item.exit_code : null,
117
- ...(typeof event.item.id === 'string' && event.item.id.trim()
118
- ? { itemId: event.item.id.trim() }
119
- : {}),
120
- });
121
- }
122
- break;
123
- case 'turn.completed':
124
- onEvent({ type: 'turn.completed', usage: event.usage });
125
- break;
126
- case 'error':
127
- lastErrorText = normalizeErrorText(event.message) ?? lastErrorText;
128
- break;
129
- case 'turn.failed':
130
- lastErrorText = normalizeErrorText(event.error?.message ?? event.message) ?? lastErrorText;
131
- break;
132
- default:
133
- break;
134
- }
135
- });
136
- stderr.on('line', (line) => {
137
- const trimmed = line.trim();
138
- if (!trimmed)
139
- return;
140
- if (isIgnorableCodexLog(trimmed))
141
- return;
142
- lastErrorText = trimmed;
143
- if (isRecoverableCodexThreadError(trimmed))
144
- return;
145
- onLog?.(trimmed);
146
- });
147
- return await new Promise((resolve, reject) => {
148
- child.on('error', (error) => {
149
- this.clearActiveProcess();
150
- reject(error);
151
- });
152
- child.on('close', (code) => {
153
- stdout.close();
154
- stderr.close();
155
- const interrupted = this.interrupted || code === 130;
156
- const result = {
157
- threadId: this.threadId,
158
- finalMessage: latestMessage,
159
- exitCode: code,
160
- interrupted,
161
- errorText: interrupted ? null : lastErrorText,
162
- };
163
- this.clearActiveProcess();
164
- resolve(result);
165
- });
166
- });
167
- }
168
- buildAddDirs(extraAddDirs = []) {
169
- const seen = new Set();
170
- const dirs = [];
171
- for (const value of [...this.addDirs, ...extraAddDirs]) {
172
- const trimmed = value.trim();
173
- if (!trimmed || seen.has(trimmed))
174
- continue;
175
- seen.add(trimmed);
176
- dirs.push(value);
177
- }
178
- return dirs;
179
- }
180
- buildArgs(prompt, imagePaths = [], extraAddDirs = []) {
181
- if (this.threadId && this.canResumeWithCurrentPolicy()) {
182
- const args = ['exec', 'resume', '--json', '--skip-git-repo-check'];
183
- if (this.model) {
184
- args.push('-m', this.model);
185
- }
186
- if (this.codexProfile) {
187
- args.push('-p', this.codexProfile);
188
- }
189
- for (const configOverride of this.configOverrides) {
190
- args.push('-c', configOverride);
191
- }
192
- if (this.reasoningEffort) {
193
- args.push('-c', `model_reasoning_effort="${this.reasoningEffort}"`);
194
- }
195
- for (const addDir of this.buildAddDirs(extraAddDirs)) {
196
- args.push('--add-dir', addDir);
197
- }
198
- if (this.fullAuto) {
199
- args.push('--full-auto');
200
- }
201
- if (this.bypassApprovalsAndSandbox) {
202
- args.push('--dangerously-bypass-approvals-and-sandbox');
203
- }
204
- for (const imagePath of imagePaths) {
205
- args.push('-i', imagePath);
206
- }
207
- if (imagePaths.length > 0) {
208
- args.push('--');
209
- }
210
- args.push(this.threadId, prompt);
211
- return args;
212
- }
213
- if (this.threadId) {
214
- this.threadId = null;
215
- }
216
- const args = ['exec', '--json', '--color', 'never', '-C', this.cwd, '--skip-git-repo-check'];
217
- const execMode = resolveExecMode({
218
- sandbox: this.sandbox,
219
- fullAuto: this.fullAuto,
220
- bypassApprovalsAndSandbox: this.bypassApprovalsAndSandbox,
221
- });
222
- if (this.model) {
223
- args.push('-m', this.model);
224
- }
225
- if (this.sandbox) {
226
- args.push('-s', this.sandbox);
227
- }
228
- if (this.codexProfile) {
229
- args.push('-p', this.codexProfile);
230
- }
231
- for (const addDir of this.buildAddDirs(extraAddDirs)) {
232
- args.push('--add-dir', addDir);
233
- }
234
- for (const configOverride of this.configOverrides) {
235
- args.push('-c', configOverride);
236
- }
237
- if (this.reasoningEffort) {
238
- args.push('-c', `model_reasoning_effort="${this.reasoningEffort}"`);
239
- }
240
- if (execMode.fullAuto) {
241
- args.push('--full-auto');
242
- }
243
- if (execMode.bypassApprovalsAndSandbox) {
244
- args.push('--dangerously-bypass-approvals-and-sandbox');
245
- }
246
- for (const imagePath of imagePaths) {
247
- args.push('-i', imagePath);
248
- }
249
- if (imagePaths.length > 0) {
250
- args.push('--');
251
- }
252
- args.push(prompt);
253
- return args;
254
- }
255
- canResumeWithCurrentPolicy() {
256
- if (this.bypassApprovalsAndSandbox || this.fullAuto) {
257
- return true;
258
- }
259
- return this.sandbox === null;
260
- }
261
- clearActiveProcess() {
262
- if (this.interruptTimer) {
263
- clearTimeout(this.interruptTimer);
264
- this.interruptTimer = null;
265
- }
266
- this.child = null;
267
- }
268
- }
269
- function parseEventLine(line) {
270
- const trimmed = line.trim();
271
- if (!trimmed.startsWith('{'))
272
- return null;
273
- try {
274
- return JSON.parse(trimmed);
275
- }
276
- catch {
277
- return null;
278
- }
279
- }
280
- function normalizeMessageText(value) {
281
- if (typeof value !== 'string')
282
- return null;
283
- const text = value.trim();
284
- return text ? text : null;
285
- }
286
- function normalizeErrorText(value) {
287
- if (typeof value !== 'string')
288
- return null;
289
- const text = value.trim();
290
- return text ? text : null;
291
- }
292
- function resolveExecMode(input) {
293
- if (input.bypassApprovalsAndSandbox) {
294
- return { fullAuto: false, bypassApprovalsAndSandbox: true };
295
- }
296
- if (input.fullAuto) {
297
- return { fullAuto: true, bypassApprovalsAndSandbox: false };
298
- }
299
- return { fullAuto: false, bypassApprovalsAndSandbox: false };
300
- }
301
- function isIgnorableCodexLog(line) {
302
- return [
303
- 'Reading additional input from stdin...',
304
- 'ignoring interface.defaultPrompt',
305
- 'state db discrepancy during find_thread_path_by_id_str_in_subdir',
306
- 'failed to open state db',
307
- 'failed to initialize state runtime',
308
- 'Failed to delete shell snapshot',
309
- ].some((pattern) => line.includes(pattern));
310
- }