@canonmsg/codex-plugin 0.19.2 → 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;
@@ -47,7 +99,6 @@ export declare class CodexAppServerAdapter {
47
99
  model?: string | null;
48
100
  reasoningEffort?: string | null;
49
101
  sandbox?: CodexSandboxMode | null;
50
- approvalPolicy?: CodexApprovalPolicy | null;
51
102
  addDirs?: string[];
52
103
  configOverrides?: string[];
53
104
  fullAuto?: boolean;
@@ -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;
@@ -39,7 +38,6 @@ export class CodexAppServerAdapter {
39
38
  this.model = opts.model ?? null;
40
39
  this.reasoningEffort = opts.reasoningEffort ?? null;
41
40
  this.sandbox = opts.sandbox ?? null;
42
- this.legacyApprovalPolicy = opts.approvalPolicy ?? null;
43
41
  this.addDirs = opts.addDirs ?? [];
44
42
  this.configOverrides = opts.configOverrides ?? [];
45
43
  this.fullAuto = opts.fullAuto ?? false;
@@ -193,7 +191,7 @@ export class CodexAppServerAdapter {
193
191
  resolveApprovalPolicy() {
194
192
  if (this.bypassApprovalsAndSandbox || this.fullAuto)
195
193
  return 'never';
196
- return this.legacyApprovalPolicy;
194
+ return null;
197
195
  }
198
196
  configPayload() {
199
197
  const config = {};
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';
@@ -348,9 +346,7 @@ function resolveCodexEffectiveRuntimePolicy(input) {
348
346
  }
349
347
  const approvalOverride = mapCanonPermissionToCodex(permissionMode);
350
348
  const defaultSandbox = (stringArg(input.args, 'sandbox') ?? null);
351
- const defaultApprovalPolicy = (stringArg(input.args, 'ask-for-approval') ?? null);
352
349
  const sandbox = approvalOverride ? approvalOverride.sandbox : defaultSandbox;
353
- const approvalPolicy = approvalOverride ? null : defaultApprovalPolicy;
354
350
  const fullAuto = approvalOverride ? approvalOverride.fullAuto : boolArg(input.args, 'full-auto');
355
351
  const bypassApprovalsAndSandbox = approvalOverride
356
352
  ? approvalOverride.bypassApprovalsAndSandbox
@@ -360,7 +356,6 @@ function resolveCodexEffectiveRuntimePolicy(input) {
360
356
  executionMode: input.environment.mode,
361
357
  permissionMode: permissionMode ?? null,
362
358
  sandbox,
363
- approvalPolicy,
364
359
  fullAuto,
365
360
  bypassApprovalsAndSandbox,
366
361
  });
@@ -368,7 +363,6 @@ function resolveCodexEffectiveRuntimePolicy(input) {
368
363
  ...(model ? { model } : {}),
369
364
  ...(permissionMode ? { permissionMode } : {}),
370
365
  sandbox,
371
- approvalPolicy,
372
366
  fullAuto,
373
367
  bypassApprovalsAndSandbox,
374
368
  fingerprint,
@@ -426,17 +420,6 @@ function stringArgs(value) {
426
420
  ? value.filter((item) => typeof item === 'string' && item.trim().length > 0)
427
421
  : undefined;
428
422
  }
429
- function supportsCodexAppServer(codexBin) {
430
- if (process.env.CANON_CODEX_TRANSPORT === 'exec')
431
- return false;
432
- if (process.env.CANON_CODEX_TRANSPORT === 'app-server')
433
- return true;
434
- const result = spawnSync(codexBin, ['app-server', '--help'], {
435
- encoding: 'utf8',
436
- stdio: ['ignore', 'ignore', 'ignore'],
437
- });
438
- return result.status === 0;
439
- }
440
423
  function parsePlanCommand(content) {
441
424
  const trimmed = content.trimStart();
442
425
  if (!trimmed.startsWith('/plan'))
@@ -512,7 +495,6 @@ export async function main() {
512
495
  model: { type: 'string' },
513
496
  sandbox: { type: 'string' },
514
497
  'ask-for-approval': { type: 'string' },
515
- 'codex-profile': { type: 'string' },
516
498
  'add-dir': { type: 'string', multiple: true },
517
499
  workspace: { type: 'string', multiple: true },
518
500
  'workspace-root': { type: 'string', multiple: true },
@@ -545,14 +527,12 @@ export async function main() {
545
527
  }
546
528
  const codexBin = typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex';
547
529
  const codexCliStatus = detectCodexCliVersion(codexBin);
548
- const useAppServer = supportsCodexAppServer(codexBin);
549
530
  if (codexCliStatus.version) {
550
531
  console.error(`[canon-codex] Detected Codex CLI ${codexCliStatus.version} (${codexBin})`);
551
532
  }
552
533
  else {
553
534
  console.error(`[canon-codex] Could not detect Codex CLI version for ${codexBin}: ${codexCliStatus.error ?? 'unknown result'}`);
554
535
  }
555
- console.error(`[canon-codex] Codex transport: ${useAppServer ? 'app-server' : 'exec --json'}`);
556
536
  const { agentName: profileAgentName, profile, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
557
537
  activeLockHandle = lockHandle ?? null;
558
538
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
@@ -747,35 +727,19 @@ export async function main() {
747
727
  const initialEffort = config?.effort && CODEX_EFFORT_VALUES.has(config.effort)
748
728
  ? config.effort
749
729
  : null;
750
- const adapter = useAppServer
751
- ? new CodexAppServerAdapter({
752
- cwd: sessionCwd,
753
- threadId: storedThreadId,
754
- codexBin,
755
- model: policy.model ?? null,
756
- reasoningEffort: initialEffort,
757
- sandbox: policy.sandbox,
758
- approvalPolicy: policy.approvalPolicy,
759
- addDirs: args['add-dir'] ?? [],
760
- configOverrides: args.config ?? [],
761
- fullAuto: policy.fullAuto,
762
- bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
763
- dynamicTools: CODEX_APP_DYNAMIC_TOOLS,
764
- })
765
- : new CodexConversationAdapter({
766
- cwd: sessionCwd,
767
- threadId: storedThreadId,
768
- codexBin,
769
- model: policy.model ?? null,
770
- reasoningEffort: initialEffort,
771
- sandbox: policy.sandbox,
772
- approvalPolicy: policy.approvalPolicy,
773
- codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
774
- addDirs: args['add-dir'] ?? [],
775
- configOverrides: args.config ?? [],
776
- fullAuto: policy.fullAuto,
777
- bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
778
- });
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
+ });
779
743
  // eslint-disable-next-line prefer-const -- session must be declared before the module closures but assigned after them
780
744
  let session;
781
745
  // ── Per-session RTDB writers (generic skeleton from @canonmsg/agent-host) ──
@@ -1338,9 +1302,6 @@ export async function main() {
1338
1302
  const params = request.params;
1339
1303
  const expiresAt = Date.now() + 30 * 60_000;
1340
1304
  if (request.method === 'item/tool/call' && isCodexAppToolCall(params)) {
1341
- if (!(session.adapter instanceof CodexAppServerAdapter)) {
1342
- return deniedCodexAppToolResult('codex_app tools require the Codex app-server transport.');
1343
- }
1344
1305
  if (!session.currentTurnCanUseCodexAppTools) {
1345
1306
  return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
1346
1307
  }
@@ -1571,11 +1532,9 @@ export async function main() {
1571
1532
  const renderedContent = renderInboundContent(input.message, materialized);
1572
1533
  const turnMetadata = normalizeTurnMetadata(input.message.metadata);
1573
1534
  const requestedPlanMode = turnMetadata?.requestedTurnMode === 'plan';
1574
- const planCommand = useAppServer
1575
- ? requestedPlanMode
1576
- ? { planMode: true, content: renderedContent }
1577
- : parsePlanCommand(renderedContent)
1578
- : { planMode: false, content: renderedContent };
1535
+ const planCommand = requestedPlanMode
1536
+ ? { planMode: true, content: renderedContent }
1537
+ : parsePlanCommand(renderedContent);
1579
1538
  const content = planCommand.content;
1580
1539
  const hydrated = await loadHydratedInboundContext({
1581
1540
  conversationId: input.conversationId,
@@ -1750,16 +1709,14 @@ export async function main() {
1750
1709
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
1751
1710
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1752
1711
  presentation: runtimePresentation,
1753
- supportsPlanMode: useAppServer,
1754
- supportsCompact: useAppServer,
1755
- supportsRichCards: useAppServer,
1712
+ supportsPlanMode: true,
1713
+ supportsCompact: true,
1714
+ supportsRichCards: true,
1756
1715
  skills: codexSkills,
1757
1716
  }),
1758
1717
  });
1759
1718
  let runtimeDescriptor = buildCurrentRuntimeDescriptor();
1760
1719
  async function refreshCodexSkillInventory(forceReload = false) {
1761
- if (!useAppServer)
1762
- return;
1763
1720
  const probe = new CodexAppServerAdapter({
1764
1721
  cwd: workingDir,
1765
1722
  codexBin,
@@ -1844,10 +1801,6 @@ export async function main() {
1844
1801
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Cannot compact context: no live Codex session`);
1845
1802
  return;
1846
1803
  }
1847
- if (!(session.adapter instanceof CodexAppServerAdapter)) {
1848
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Cannot compact context: compact is only available for Codex app-server sessions`);
1849
- return;
1850
- }
1851
1804
  try {
1852
1805
  await session.adapter.compactThread();
1853
1806
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Compact requested`);
@@ -1930,24 +1883,16 @@ export async function main() {
1930
1883
  const payload = {
1931
1884
  descriptor,
1932
1885
  surfaceMode: 'host',
1933
- // The exec --json transport cannot block on approvals — without a
1934
- // strip-level warning a user can believe they have an approval
1935
- // gate they do not have.
1936
- ...(useAppServer
1937
- ? {}
1938
- : { warning: "Approvals can't block on this Codex CLI — update Codex to enable the app-server transport and approval gates." }),
1939
1886
  statusItems: [
1940
1887
  {
1941
1888
  id: 'transport',
1942
1889
  label: 'Transport',
1943
- value: useAppServer ? 'app-server' : 'exec --json',
1890
+ value: 'app-server',
1944
1891
  },
1945
1892
  {
1946
1893
  id: 'streaming',
1947
1894
  label: 'Live output',
1948
- value: useAppServer
1949
- ? 'Plans, questions, approvals, tools, and message deltas'
1950
- : 'Thinking, tools, and completed-message previews',
1895
+ value: 'Plans, questions, approvals, tools, and message deltas',
1951
1896
  },
1952
1897
  {
1953
1898
  id: 'codex-cli',
@@ -1958,8 +1903,7 @@ export async function main() {
1958
1903
  {
1959
1904
  id: 'nativeActions',
1960
1905
  label: 'Native actions',
1961
- value: useAppServer ? 'Enabled' : 'Limited until app-server transport',
1962
- ...(useAppServer ? {} : { tone: 'warning' }),
1906
+ value: 'Enabled',
1963
1907
  },
1964
1908
  {
1965
1909
  id: 'mediaOut',
@@ -1978,9 +1922,7 @@ export async function main() {
1978
1922
  fallbackReason: resolveExecutionFallbackReason(session?.environment),
1979
1923
  },
1980
1924
  notes: [
1981
- useAppServer
1982
- ? 'This Codex host uses the app-server transport, so Canon can route native plan mode, runtime questions, approvals, and live turn updates.'
1983
- : '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.',
1984
1926
  ],
1985
1927
  };
1986
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.19.2",
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.4.0",
33
- "@canonmsg/agent-sdk": "^3.4.4",
34
- "@canonmsg/backend-contracts": "^1.8.1",
35
- "@canonmsg/bridge": "^0.2.1",
36
- "@canonmsg/core": "^3.2.0",
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,97 +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
- 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
- }
55
- export declare class CodexConversationAdapter {
56
- private readonly cwd;
57
- private readonly codexBin;
58
- private model;
59
- private reasoningEffort;
60
- private readonly sandbox;
61
- private readonly legacyApprovalPolicy;
62
- private readonly codexProfile;
63
- private readonly addDirs;
64
- private readonly configOverrides;
65
- private readonly fullAuto;
66
- private readonly bypassApprovalsAndSandbox;
67
- private child;
68
- private threadId;
69
- private interruptTimer;
70
- private interrupted;
71
- constructor(opts: {
72
- cwd: string;
73
- threadId?: string | null;
74
- codexBin?: string;
75
- model?: string | null;
76
- reasoningEffort?: string | null;
77
- sandbox?: CodexSandboxMode | null;
78
- approvalPolicy?: CodexApprovalPolicy | null;
79
- codexProfile?: string | null;
80
- addDirs?: string[];
81
- configOverrides?: string[];
82
- fullAuto?: boolean;
83
- bypassApprovalsAndSandbox?: boolean;
84
- });
85
- getThreadId(): string | null;
86
- clearThreadId(): void;
87
- setModel(model: string | null): void;
88
- /** Sets GPT reasoning effort, applied on the next turn via `-c model_reasoning_effort`. */
89
- setReasoningEffort(effort: string | null): void;
90
- isRunning(): boolean;
91
- interrupt(): Promise<void>;
92
- runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void, imagePaths?: readonly string[], extraAddDirs?: readonly string[], _options?: CodexRunTurnOptions): Promise<CodexTurnResult>;
93
- private buildAddDirs;
94
- private buildArgs;
95
- private canResumeWithCurrentPolicy;
96
- private clearActiveProcess;
97
- }
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
- }