@canonmsg/codex-plugin 0.26.2 → 0.28.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.
@@ -1,4 +1,4 @@
1
- import { type NoReplyReportClient } from '@canonmsg/core';
1
+ import { type CanonClient, type NoReplyReportClient } from '@canonmsg/core';
2
2
  type JsonRecord = Record<string, unknown>;
3
3
  interface DynamicToolSpec {
4
4
  [key: string]: unknown;
@@ -44,6 +44,7 @@ type DynamicToolCallResponse = {
44
44
  */
45
45
  export declare const CODEX_NO_REPLY_TOOL_NAME = "no_reply";
46
46
  export declare const CANON_RUNTIME_CONTROL_TOOL_NAME = "canon_runtime_control";
47
+ export declare const CODEX_COMMUNICATE_TOOL_NAME = "communicate";
47
48
  /**
48
49
  * What the model actually sees for the tool above, for prompt text that names
49
50
  * it (the group posture cue). Only meaningful on the app-server transport —
@@ -53,6 +54,8 @@ export declare const CODEX_NO_REPLY_MODEL_TOOL_NAME = "codex_app.no_reply";
53
54
  export declare const CODEX_APP_DYNAMIC_TOOLS: ReadonlyArray<DynamicToolSpec>;
54
55
  /** The complete model-visible surface for non-coding service agents. */
55
56
  export declare const CODEX_SERVICE_AGENT_DYNAMIC_TOOLS: ReadonlyArray<DynamicToolSpec>;
57
+ /** Closed agents are not shown the optional outbound communication surface. */
58
+ export declare function filterCodexCommunicationTools(tools: ReadonlyArray<DynamicToolSpec>, outboundPolicy: 'open' | 'approval-required' | 'closed' | null): ReadonlyArray<DynamicToolSpec>;
56
59
  /**
57
60
  * Tools exposed to detached Codex threads created through the bridge. Canon
58
61
  * conversation controls belong only to the foreground turn that received the
@@ -65,7 +68,9 @@ export declare function successfulCodexAppToolResult(payload: unknown): DynamicT
65
68
  /** True when this dynamic-tool call is the deliberate-silence verb. */
66
69
  export declare function isCodexNoReplyToolCall(params: CodexAppToolCallParams): boolean;
67
70
  export declare function isCanonRuntimeControlToolCall(params: CodexAppToolCallParams): boolean;
71
+ export declare function isCodexCommunicateToolCall(params: CodexAppToolCallParams): boolean;
68
72
  export declare function isCodexServiceAgentToolCall(params: CodexAppToolCallParams): boolean;
73
+ export declare function handleCodexCommunicateToolCall(client: CanonClient, params: CodexAppToolCallParams): Promise<DynamicToolCallResponse>;
69
74
  export interface CanonRuntimeControlRequest {
70
75
  action: 'send_card' | 'request_card' | 'request_input';
71
76
  card?: unknown;
@@ -1,4 +1,5 @@
1
- import { NO_REPLY_ACK_NOTE, reportNoReplyOutcome } from '@canonmsg/core';
1
+ import { NO_REPLY_ACK_NOTE, reportNoReplyOutcome, } from '@canonmsg/core';
2
+ import { canonCommunicateToolDefinition, createCanonCommunicationBinding, } from '@canonmsg/agent-tools';
2
3
  const emptyObjectSchema = {
3
4
  type: 'object',
4
5
  properties: {},
@@ -90,6 +91,7 @@ function tool(name, description, inputSchema, deferLoading = true) {
90
91
  */
91
92
  export const CODEX_NO_REPLY_TOOL_NAME = 'no_reply';
92
93
  export const CANON_RUNTIME_CONTROL_TOOL_NAME = 'canon_runtime_control';
94
+ export const CODEX_COMMUNICATE_TOOL_NAME = 'communicate';
93
95
  /**
94
96
  * What the model actually sees for the tool above, for prompt text that names
95
97
  * it (the group posture cue). Only meaningful on the app-server transport —
@@ -121,6 +123,11 @@ const CANON_RUNTIME_CONTROL_TOOL = tool(CANON_RUNTIME_CONTROL_TOOL_NAME, 'Intera
121
123
  },
122
124
  required: ['action'],
123
125
  }, false);
126
+ const communicationBinding = createCanonCommunicationBinding({
127
+ toolName: CODEX_COMMUNICATE_TOOL_NAME,
128
+ });
129
+ const communicateDefinition = canonCommunicateToolDefinition(CODEX_COMMUNICATE_TOOL_NAME);
130
+ const CODEX_COMMUNICATE_TOOL = tool(communicateDefinition.name, communicateDefinition.description, communicateDefinition.inputSchema, false);
124
131
  const CODEX_NO_REPLY_TOOL = tool(CODEX_NO_REPLY_TOOL_NAME, 'End your turn without posting anything to the conversation. Use it in group '
125
132
  + 'chats when you have nothing to add — no message is created, so no other '
126
133
  + 'member or agent is triggered. Optional private reason (logged, never '
@@ -245,6 +252,7 @@ export const CODEX_APP_DYNAMIC_TOOLS = [
245
252
  required: ['threadId', 'title'],
246
253
  }),
247
254
  CANON_RUNTIME_CONTROL_TOOL,
255
+ CODEX_COMMUNICATE_TOOL,
248
256
  // Canon's `no_reply` verb, projected into the only model-visible tool surface
249
257
  // the Codex transport gives us. It is answered by the Canon host itself.
250
258
  CODEX_NO_REPLY_TOOL,
@@ -252,8 +260,15 @@ export const CODEX_APP_DYNAMIC_TOOLS = [
252
260
  /** The complete model-visible surface for non-coding service agents. */
253
261
  export const CODEX_SERVICE_AGENT_DYNAMIC_TOOLS = [
254
262
  CANON_RUNTIME_CONTROL_TOOL,
263
+ CODEX_COMMUNICATE_TOOL,
255
264
  CODEX_NO_REPLY_TOOL,
256
265
  ];
266
+ /** Closed agents are not shown the optional outbound communication surface. */
267
+ export function filterCodexCommunicationTools(tools, outboundPolicy) {
268
+ return outboundPolicy === 'closed'
269
+ ? tools.filter((entry) => entry.name !== CODEX_COMMUNICATE_TOOL_NAME)
270
+ : tools;
271
+ }
257
272
  /**
258
273
  * Tools exposed to detached Codex threads created through the bridge. Canon
259
274
  * conversation controls belong only to the foreground turn that received the
@@ -294,9 +309,21 @@ export function isCodexNoReplyToolCall(params) {
294
309
  export function isCanonRuntimeControlToolCall(params) {
295
310
  return normalizeToolName(params.tool) === CANON_RUNTIME_CONTROL_TOOL_NAME;
296
311
  }
312
+ export function isCodexCommunicateToolCall(params) {
313
+ return normalizeToolName(params.tool) === CODEX_COMMUNICATE_TOOL_NAME;
314
+ }
297
315
  export function isCodexServiceAgentToolCall(params) {
298
316
  const toolName = normalizeToolName(params.tool);
299
- return toolName === CANON_RUNTIME_CONTROL_TOOL_NAME || toolName === CODEX_NO_REPLY_TOOL_NAME;
317
+ return toolName === CANON_RUNTIME_CONTROL_TOOL_NAME
318
+ || toolName === CODEX_COMMUNICATE_TOOL_NAME
319
+ || toolName === CODEX_NO_REPLY_TOOL_NAME;
320
+ }
321
+ export async function handleCodexCommunicateToolCall(client, params) {
322
+ const result = await communicationBinding.execute(client, CODEX_COMMUNICATE_TOOL_NAME, parseToolArguments(params.arguments));
323
+ return {
324
+ success: result.isError !== true,
325
+ contentItems: result.content.map((item) => ({ type: 'inputText', text: item.text })),
326
+ };
300
327
  }
301
328
  export function parseCanonRuntimeControlRequest(params) {
302
329
  if (!isCanonRuntimeControlToolCall(params))
package/dist/host.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { type TurnArtifactRoutingDecision, type TurnArtifactRoutingMode } from '@canonmsg/coding-agent-host';
2
+ import { type TurnArtifactRoutingDecision, type TurnArtifactRoutingMode, type RecoveryCheckpointTracker } from '@canonmsg/coding-agent-host';
3
3
  import { type CanonRuntimeCommandDescriptor, type CanonRuntimeDescriptor, type CanonRuntimePresentationPolicy, type ExecutionEnvironmentMode, type PreparedExecutionEnvironment, type WorkspaceOption, type CanonWorkspaceRootMetadata, type RuntimeStreamingPayload, type TurnLifecycleState, type TurnOutputBlock, type TurnVerbosity, type TurnVerbosityConfig } from '@canonmsg/core';
4
4
  import { type CodexSandboxMode } from './adapter.js';
5
5
  import { type CodexSkillMetadata } from './app-server-adapter.js';
@@ -49,6 +49,7 @@ export declare function buildCodexServiceSnapshotConfig(input: {
49
49
  executionMode: "locked";
50
50
  executionBranch: null;
51
51
  };
52
+ export declare function createCodexRecoveryCheckpointTracker(persist: (messageId: string) => boolean): RecoveryCheckpointTracker;
52
53
  /** Conservative fallback used only when native app-server discovery is unavailable. */
53
54
  export declare const CODEX_EFFORT_OPTIONS: readonly CodexControlOption[];
54
55
  export declare const CODEX_SESSION_CONFIG_FIELDS: readonly ["permissionMode", "effort"];
@@ -60,6 +61,7 @@ export declare function buildCodexRuntimeDescriptor(input: {
60
61
  workspaces: WorkspaceOption[];
61
62
  workspaceRoots?: CanonWorkspaceRootMetadata[];
62
63
  executionModes: ExecutionEnvironmentMode[];
64
+ defaultExecutionMode?: ExecutionEnvironmentMode;
63
65
  permissionModes: Array<{
64
66
  value: string;
65
67
  label: string;
@@ -86,7 +88,7 @@ export declare function getCodexRequestingUserId(message: {
86
88
  }): string | null;
87
89
  export declare function resolveSessionExecutionMode(config: {
88
90
  executionMode?: ExecutionEnvironmentMode;
89
- } | null | undefined, serviceAgentMode?: boolean): ExecutionEnvironmentMode;
91
+ } | null | undefined, serviceAgentMode?: boolean, defaultExecutionMode?: ExecutionEnvironmentMode): ExecutionEnvironmentMode;
90
92
  export declare function resolveCodexSessionConfig<T extends Record<string, unknown>>(config: T | null | undefined, serviceAgentMode?: boolean): T | null;
91
93
  export declare function resolveWorkspaceCwd(config: {
92
94
  workspaceId?: string;
package/dist/host.js CHANGED
@@ -5,12 +5,12 @@ import { spawnSync } from 'node:child_process';
5
5
  import { dirname } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
8
- import { buildTrailBlockId, buildUndeliverableFinalNotice, captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, resolveTurnArtifactRouting, collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from '@canonmsg/coding-agent-host';
8
+ import { buildTrailBlockId, buildUndeliverableFinalNotice, captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, resolveTurnArtifactRouting, collectMissedInboundMessages, createRecoveryCheckpointTracker, createReconnectRecoveryCoordinator, STARTUP_RECOVERY_PAGE_SIZE, } from '@canonmsg/coding-agent-host';
9
9
  import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
10
10
  import { validateCard } from '@canonmsg/rich-cards';
11
11
  import { CodexConversationAdapter, } from './adapter.js';
12
12
  import { CodexAppServerAdapter, } from './app-server-adapter.js';
13
- import { CODEX_APP_DYNAMIC_TOOLS, CODEX_NO_REPLY_MODEL_TOOL_NAME, CODEX_SERVICE_AGENT_DYNAMIC_TOOLS, answerCodexNoReply, classifyCodexAppToolRequest, deniedCodexAppToolResult, handleCodexAppToolCall, isCanonRuntimeControlToolCall, isCodexAppToolCall, isCodexServiceAgentToolCall, parseCanonRuntimeControlRequest, readCodexNoReplyReason, successfulCodexAppToolResult, } from './codex-app-tools.js';
13
+ import { CODEX_APP_DYNAMIC_TOOLS, CODEX_NO_REPLY_MODEL_TOOL_NAME, CODEX_SERVICE_AGENT_DYNAMIC_TOOLS, answerCodexNoReply, classifyCodexAppToolRequest, deniedCodexAppToolResult, filterCodexCommunicationTools, handleCodexAppToolCall, handleCodexCommunicateToolCall, isCanonRuntimeControlToolCall, isCodexAppToolCall, isCodexCommunicateToolCall, isCodexServiceAgentToolCall, parseCanonRuntimeControlRequest, readCodexNoReplyReason, successfulCodexAppToolResult, } from './codex-app-tools.js';
14
14
  import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
15
15
  import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
16
16
  import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
@@ -30,6 +30,7 @@ COMMON FLAGS
30
30
  --cwd <path> Project directory to run Codex from
31
31
  --workspace <path> Additional project to expose in Canon
32
32
  --workspace-root <path> Discover projects under an approved root
33
+ --default-execution-mode <mode> New-conversation default: worktree or locked
33
34
  --model <model> Default Codex model for new turns
34
35
  --sandbox <mode> Codex sandbox mode
35
36
  --full-auto Allow non-interactive write access
@@ -93,6 +94,9 @@ export function buildCodexServiceSnapshotConfig(input) {
93
94
  };
94
95
  }
95
96
  const MAX_SESSIONS = 12;
97
+ export function createCodexRecoveryCheckpointTracker(persist) {
98
+ return createRecoveryCheckpointTracker(persist);
99
+ }
96
100
  // IDLE_TIMEOUT_MS (30 minutes) is shared with the other coding-agent hosts.
97
101
  const HEARTBEAT_MS = 30_000;
98
102
  /** How Codex says it finished the work but Canon would not take the answer. */
@@ -196,6 +200,7 @@ export function buildCodexRuntimeDescriptor(input) {
196
200
  workspaces: serviceAgentMode ? [] : input.workspaces,
197
201
  workspaceRoots: serviceAgentMode ? undefined : input.workspaceRoots,
198
202
  executionModes: serviceAgentMode ? [] : input.executionModes,
203
+ defaultExecutionMode: serviceAgentMode ? 'locked' : input.defaultExecutionMode,
199
204
  permissionModes: serviceAgentMode ? [] : input.permissionModes,
200
205
  defaultPermissionMode: serviceAgentMode ? undefined : input.defaultPermissionMode,
201
206
  permissionModeLabel: 'Execution policy',
@@ -286,12 +291,17 @@ async function loadSessionConfig(conversationId, agentId, rtdb) {
286
291
  extraStringFields: CODEX_SESSION_CONFIG_FIELDS,
287
292
  });
288
293
  }
289
- export function resolveSessionExecutionMode(config, serviceAgentMode = false) {
294
+ export function resolveSessionExecutionMode(config, serviceAgentMode = false, defaultExecutionMode = 'worktree') {
290
295
  if (serviceAgentMode)
291
296
  return 'locked';
292
- if (config?.executionMode)
293
- return config.executionMode;
294
- throw new ExecutionEnvironmentError('Session config is missing an execution mode.', 'Choose Isolated worktree or Use shared project before starting this coding session.');
297
+ return config?.executionMode ?? defaultExecutionMode;
298
+ }
299
+ function resolveConfiguredDefaultExecutionMode(value) {
300
+ if (value == null || value === '')
301
+ return 'worktree';
302
+ if (value === 'worktree' || value === 'locked')
303
+ return value;
304
+ throw new Error('--default-execution-mode must be worktree or locked');
295
305
  }
296
306
  export function resolveCodexSessionConfig(config, serviceAgentMode = false) {
297
307
  return serviceAgentMode ? null : config ?? null;
@@ -633,6 +643,7 @@ export async function main() {
633
643
  'add-dir': { type: 'string', multiple: true },
634
644
  workspace: { type: 'string', multiple: true },
635
645
  'workspace-root': { type: 'string', multiple: true },
646
+ 'default-execution-mode': { type: 'string' },
636
647
  config: { type: 'string', multiple: true },
637
648
  'codex-bin': { type: 'string' },
638
649
  'runtime-visibility': { type: 'string' },
@@ -657,8 +668,11 @@ export async function main() {
657
668
  onWarning: (message) => console.error(`[canon-codex] ${message}`),
658
669
  });
659
670
  const serviceAgentMode = args['service-agent'] === true;
671
+ const defaultExecutionMode = serviceAgentMode
672
+ ? 'locked'
673
+ : resolveConfiguredDefaultExecutionMode(args['default-execution-mode']);
660
674
  const nativeVisionEnabled = args['no-native-vision'] !== true;
661
- const codexDynamicTools = serviceAgentMode
675
+ const baseCodexDynamicTools = serviceAgentMode
662
676
  ? CODEX_SERVICE_AGENT_DYNAMIC_TOOLS
663
677
  : CODEX_APP_DYNAMIC_TOOLS;
664
678
  workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
@@ -703,11 +717,13 @@ export async function main() {
703
717
  let agentId;
704
718
  let ownerId = null;
705
719
  let ownerName = null;
720
+ let outboundPolicy = null;
706
721
  try {
707
722
  const ctx = await client.getAgentMe();
708
723
  agentId = ctx.agentId;
709
724
  ownerId = ctx.ownerId;
710
725
  ownerName = ctx.ownerName;
726
+ outboundPolicy = ctx.outboundPolicy;
711
727
  console.error(`[canon-codex] Connected as ${ctx.displayName || agentId}`);
712
728
  }
713
729
  catch {
@@ -720,6 +736,7 @@ export async function main() {
720
736
  }
721
737
  console.error(`[canon-codex] Authenticated as ${agentId}`);
722
738
  }
739
+ const codexDynamicTools = filterCodexCommunicationTools(baseCodexDynamicTools, outboundPolicy);
723
740
  // Shared poll/timeout engine. Built-in `input`/`card` descriptors own
724
741
  // create+poll (codex passes native/responder policy via payload/options).
725
742
  // Approval keeps codex's own resolution shape (no owner-authored outcome
@@ -816,7 +833,15 @@ export async function main() {
816
833
  });
817
834
  const sessions = new Map();
818
835
  const pendingSessionCreations = new Map();
819
- let inboundRecoverySequence = 0;
836
+ const recoveryCheckpointTrackers = new Map();
837
+ function recoveryCheckpointsFor(conversationId) {
838
+ let tracker = recoveryCheckpointTrackers.get(conversationId);
839
+ if (!tracker) {
840
+ tracker = createCodexRecoveryCheckpointTracker((messageId) => persistInboundRecoveryCursor(conversationId, messageId));
841
+ recoveryCheckpointTrackers.set(conversationId, tracker);
842
+ }
843
+ return tracker;
844
+ }
820
845
  const conversationCache = new Map();
821
846
  const knownConversationIds = new Set();
822
847
  const promptedGroupContextConversationIds = new Set();
@@ -964,27 +989,20 @@ export async function main() {
964
989
  }
965
990
  function persistInboundRecoveryCursor(conversationId, messageId) {
966
991
  if (!messageId)
967
- return;
992
+ return true;
968
993
  try {
969
994
  saveRuntimeSessionState(runtimeId, {
970
995
  conversationId,
971
996
  baseCwd: workingDir,
972
997
  lastInboundMessageId: messageId,
973
998
  });
999
+ return true;
974
1000
  }
975
1001
  catch (error) {
976
1002
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
1003
+ return false;
977
1004
  }
978
1005
  }
979
- function persistInboundRecoveryCursorWhenIdle(conversationId, messageId) {
980
- const session = sessions.get(conversationId);
981
- // A later queued turn will advance past this non-triggering message. Do
982
- // not write it ahead of earlier work and then let that work regress the
983
- // cursor when it completes.
984
- if (session?.running || (session?.queue.length ?? 0) > 0)
985
- return;
986
- persistInboundRecoveryCursor(conversationId, messageId);
987
- }
988
1006
  async function markQueuedPromptsRejected(conversationId, prompts) {
989
1007
  await Promise.all(prompts.map((prompt) => {
990
1008
  if (!prompt.markAccepted || !prompt.sourceMessageId)
@@ -992,27 +1010,21 @@ export async function main() {
992
1010
  return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
993
1011
  }));
994
1012
  }
995
- function rememberDroppedRecoveryCursor(session, prompts) {
996
- const latest = prompts.reduce((current, prompt) => (prompt.sourceMessageId && (!current || prompt.recoverySequence > current.recoverySequence)
997
- ? prompt
998
- : current), null)?.sourceMessageId;
999
- if (!latest)
1000
- return;
1001
- if (session.running) {
1002
- session.pendingDroppedRecoveryCursor = latest;
1003
- return;
1004
- }
1005
- persistInboundRecoveryCursor(session.conversationId, latest);
1013
+ function settleRejectedPromptCheckpoints(conversationId, prompts) {
1014
+ const checkpoints = recoveryCheckpointsFor(conversationId);
1015
+ for (const prompt of prompts)
1016
+ checkpoints.settle(prompt.sourceMessageId);
1006
1017
  }
1007
1018
  function removeQueuedPrompt(conversationId, sourceMessageId) {
1008
1019
  const session = sessions.get(conversationId);
1009
1020
  if (!session || session.queue.length === 0)
1010
1021
  return;
1011
- const before = session.queue.length;
1022
+ const removed = session.queue.filter((prompt) => prompt.sourceMessageId === sourceMessageId);
1023
+ if (removed.length === 0)
1024
+ return;
1012
1025
  session.queue = session.queue.filter((prompt) => prompt.sourceMessageId !== sourceMessageId);
1013
- if (session.queue.length !== before) {
1014
- writeTurn(session);
1015
- }
1026
+ settleRejectedPromptCheckpoints(conversationId, removed);
1027
+ writeTurn(session);
1016
1028
  }
1017
1029
  function clearStreaming(conversationId) {
1018
1030
  runtimeState.clearStreaming(conversationId).catch(() => { });
@@ -1196,7 +1208,7 @@ export async function main() {
1196
1208
  session.resetRequested = true;
1197
1209
  const droppedPrompts = session.queue.splice(0);
1198
1210
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
1199
- rememberDroppedRecoveryCursor(session, droppedPrompts);
1211
+ settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
1200
1212
  clearStoredThreadId(runtimeId, conversationId, session.environment.baseCwd, session.environment.mode);
1201
1213
  session.adapter.clearThreadId();
1202
1214
  session.activeSelfContextId = null;
@@ -1250,7 +1262,7 @@ export async function main() {
1250
1262
  }
1251
1263
  const creation = (async () => {
1252
1264
  const config = resolveCodexSessionConfig(await loadSessionConfig(conversationId, agentId, rtdb), serviceAgentMode);
1253
- const sessionExecutionMode = resolveSessionExecutionMode(config, serviceAgentMode);
1265
+ const sessionExecutionMode = resolveSessionExecutionMode(config, serviceAgentMode, defaultExecutionMode);
1254
1266
  const workspaceCwd = resolveWorkspaceCwd(config, serviceAgentMode);
1255
1267
  const environment = prepareConversationEnvironment({
1256
1268
  agentId,
@@ -1331,7 +1343,6 @@ export async function main() {
1331
1343
  currentTurnSilenced: false,
1332
1344
  activeSelfContextId: null,
1333
1345
  lastAcceptedIntent: null,
1334
- pendingDroppedRecoveryCursor: null,
1335
1346
  resetRequested: false,
1336
1347
  lastActivity: Date.now(),
1337
1348
  typingKeepaliveTimer: null,
@@ -1373,7 +1384,6 @@ export async function main() {
1373
1384
  canUseCodexAppTools: turn.canUseCodexAppTools ?? false,
1374
1385
  ...(turn.turnVerbosity ? { turnVerbosity: turn.turnVerbosity } : {}),
1375
1386
  requestingUserId: turn.requestingUserId ?? null,
1376
- recoverySequence: ++inboundRecoverySequence,
1377
1387
  };
1378
1388
  if (toFront) {
1379
1389
  session.queue.unshift(nextPrompt);
@@ -1440,6 +1450,12 @@ export async function main() {
1440
1450
  const params = request.params;
1441
1451
  const expiresAt = Date.now() + 30 * 60_000;
1442
1452
  if (request.method === 'item/tool/call' && isCodexAppToolCall(params)) {
1453
+ if (isCodexCommunicateToolCall(params)) {
1454
+ if (!(session.adapter instanceof CodexAppServerAdapter)) {
1455
+ return deniedCodexAppToolResult('This Codex transport does not support dynamic tools.');
1456
+ }
1457
+ return handleCodexCommunicateToolCall(client, params);
1458
+ }
1443
1459
  if (serviceAgentMode && !isCodexServiceAgentToolCall(params)) {
1444
1460
  return deniedCodexAppToolResult('This service agent exposes only Canon conversation tools.');
1445
1461
  }
@@ -1759,7 +1775,7 @@ export async function main() {
1759
1775
  knownConversationIds.add(input.conversationId);
1760
1776
  if (input.turnDispatch && input.turnDispatch.kind !== 'run_turn') {
1761
1777
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed server-dispatched turn: ${input.turnDispatch.reason}`);
1762
- persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1778
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1763
1779
  return;
1764
1780
  }
1765
1781
  if (input.message.metadata?.type === 'plan_approval_reply') {
@@ -1767,7 +1783,7 @@ export async function main() {
1767
1783
  // A service agent never advertises or enters plan mode. Consume stale
1768
1784
  // replies left by an older coding descriptor instead of turning them
1769
1785
  // into hidden plan/implementation prompts after an upgrade or restart.
1770
- persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1786
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1771
1787
  return;
1772
1788
  }
1773
1789
  const planId = readString(input.message.metadata, 'planId');
@@ -1775,7 +1791,7 @@ export async function main() {
1775
1791
  senderId: input.message.senderId,
1776
1792
  metadata: input.message.metadata,
1777
1793
  })) {
1778
- persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1794
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1779
1795
  return;
1780
1796
  }
1781
1797
  const session = await getOrCreateSession(input.conversationId);
@@ -1849,7 +1865,7 @@ export async function main() {
1849
1865
  : decideAutoReply(participantContext, behavior);
1850
1866
  if (!autoReply.allow) {
1851
1867
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
1852
- persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1868
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1853
1869
  return;
1854
1870
  }
1855
1871
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
@@ -1873,7 +1889,7 @@ export async function main() {
1873
1889
  replyBehavior: 'suppress_auto_reply',
1874
1890
  },
1875
1891
  }).catch(() => { });
1876
- persistInboundRecoveryCursor(input.conversationId, input.message.id);
1892
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1877
1893
  return;
1878
1894
  }
1879
1895
  session.activeSelfContextId = activeSelfContextId;
@@ -2333,11 +2349,7 @@ export async function main() {
2333
2349
  finally {
2334
2350
  session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
2335
2351
  session.currentTurnAbortController = null;
2336
- persistInboundRecoveryCursor(session.conversationId, nextTurn.sourceMessageId);
2337
- if (session.pendingDroppedRecoveryCursor) {
2338
- persistInboundRecoveryCursor(session.conversationId, session.pendingDroppedRecoveryCursor);
2339
- session.pendingDroppedRecoveryCursor = null;
2340
- }
2352
+ recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
2341
2353
  stopVisibleWorkSignal(session);
2342
2354
  session.running = false;
2343
2355
  session.state.state = 'idle';
@@ -2404,6 +2416,7 @@ export async function main() {
2404
2416
  });
2405
2417
  let codexSkills = [];
2406
2418
  const buildCurrentRuntimeDescriptor = () => ({
2419
+ defaultExecutionMode,
2407
2420
  ...(serviceAgentMode
2408
2421
  ? {}
2409
2422
  : {
@@ -2423,6 +2436,7 @@ export async function main() {
2423
2436
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
2424
2437
  workspaceRoots: workspaceRootMetadata,
2425
2438
  executionModes: hostAvailableExecutionModes,
2439
+ defaultExecutionMode,
2426
2440
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
2427
2441
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
2428
2442
  presentation: runtimePresentation,
@@ -2550,7 +2564,7 @@ export async function main() {
2550
2564
  if (type === 'stop_and_drop') {
2551
2565
  const droppedPrompts = session.queue.splice(0);
2552
2566
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
2553
- rememberDroppedRecoveryCursor(session, droppedPrompts);
2567
+ settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
2554
2568
  }
2555
2569
  if (session.running) {
2556
2570
  session.currentTurnAbortController?.abort(new Error(`Codex turn interrupted by ${type}`));
@@ -2745,6 +2759,84 @@ export async function main() {
2745
2759
  publishRuntimeDetailsInFlight = false;
2746
2760
  }
2747
2761
  };
2762
+ let startupRecoveryComplete = false;
2763
+ async function recoverInboundMessageGaps() {
2764
+ // A reconnect can reveal conversations created while this host was
2765
+ // offline. Refresh before sweeping cursors; this is one REST reconciliation
2766
+ // pass, not an automatic retry loop.
2767
+ const knownBeforeRefresh = new Set(knownConversationIds);
2768
+ await refreshKnownConversationIds(true);
2769
+ const conversationsDiscoveredWhileOffline = startupRecoveryComplete
2770
+ ? new Set([...knownConversationIds].filter((id) => !knownBeforeRefresh.has(id)))
2771
+ : new Set();
2772
+ for (const conversationId of knownConversationIds) {
2773
+ const recoveryCheckpoints = recoveryCheckpointsFor(conversationId);
2774
+ const recoveryBatch = recoveryCheckpoints.reserveBatch();
2775
+ try {
2776
+ const cursor = loadRuntimeSessionState(runtimeId, {
2777
+ conversationId,
2778
+ baseCwd: workingDir,
2779
+ })?.lastInboundMessageId ?? null;
2780
+ const recovered = await collectMissedInboundMessages({
2781
+ fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
2782
+ cursor,
2783
+ agentId,
2784
+ requireContiguousCursor: true,
2785
+ noCursorMode: conversationsDiscoveredWhileOffline.has(conversationId)
2786
+ ? 'bounded-window'
2787
+ : 'latest-only',
2788
+ });
2789
+ recoveryBatch.commit(recovered.messages.map((message) => message.id), recovered.mode === 'incomplete-gap' ? recovered.recoveryCursor : null);
2790
+ if (recovered.mode === 'incomplete-gap') {
2791
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] recovery_incomplete_gap: cursor is missing; replaying ${recovered.messages.length} bounded inbound message(s) before advancing to ${recovered.recoveryCursor ?? 'no cursor'}`);
2792
+ }
2793
+ for (const message of recovered.messages) {
2794
+ const isPlanReply = isCodexPlanApprovalReply(message.metadata, serviceAgentMode);
2795
+ if (!isPlanReply && !shouldTriggerAgentTurn({
2796
+ senderType: message.senderType,
2797
+ metadata: message.metadata,
2798
+ }).allow) {
2799
+ recoveryCheckpoints.settle(message.id);
2800
+ continue;
2801
+ }
2802
+ if (!claimInboundMessageId(message.id))
2803
+ continue;
2804
+ try {
2805
+ await enqueueInboundMessage({
2806
+ conversationId,
2807
+ message,
2808
+ senderName: message.senderName || message.senderId,
2809
+ isOwner: message.senderId === ownerId,
2810
+ behavior: recovered.newestPage.behavior,
2811
+ activeSelfContextId: recovered.newestPage.activeSelfContextIdByMessageId?.[message.id] ?? null,
2812
+ selfContexts: recovered.newestPage.selfContexts,
2813
+ hydratedPage: recovered.newestPage,
2814
+ });
2815
+ settleInboundMessageId(message.id, true);
2816
+ }
2817
+ catch (error) {
2818
+ settleInboundMessageId(message.id, false);
2819
+ throw error;
2820
+ }
2821
+ }
2822
+ if (recovered.messages.length > 0) {
2823
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
2824
+ }
2825
+ }
2826
+ catch (error) {
2827
+ recoveryBatch.cancel();
2828
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovery failed:`, error instanceof Error ? error.message : error);
2829
+ }
2830
+ }
2831
+ }
2832
+ const reconnectRecovery = createReconnectRecoveryCoordinator(recoverInboundMessageGaps);
2833
+ const observeReconnectRecovery = (reason, recovery) => {
2834
+ if (!recovery)
2835
+ return;
2836
+ void recovery.catch((error) => {
2837
+ console.error(`[canon-codex] ${reason} recovery failed:`, error instanceof Error ? error.message : error);
2838
+ });
2839
+ };
2748
2840
  const stream = new CanonStream({
2749
2841
  apiKey,
2750
2842
  agentId,
@@ -2756,9 +2848,10 @@ export async function main() {
2756
2848
  return;
2757
2849
  if (!claimInboundMessageId(message.id))
2758
2850
  return;
2851
+ recoveryCheckpointsFor(payload.conversationId).track(message.id);
2759
2852
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2760
2853
  console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2761
- persistInboundRecoveryCursorWhenIdle(payload.conversationId, message.id);
2854
+ recoveryCheckpointsFor(payload.conversationId).settle(message.id);
2762
2855
  settleInboundMessageId(message.id, true);
2763
2856
  return;
2764
2857
  }
@@ -2786,6 +2879,7 @@ export async function main() {
2786
2879
  onConnected: () => {
2787
2880
  streamConnected = true;
2788
2881
  void publishRuntimeHeartbeat();
2882
+ observeReconnectRecovery('reconnect', reconnectRecovery.onConnected());
2789
2883
  console.error('[canon-codex] SSE connected');
2790
2884
  },
2791
2885
  onDisconnected: () => {
@@ -2793,6 +2887,7 @@ export async function main() {
2793
2887
  runtimeState.clearAgentRuntime().catch(() => { });
2794
2888
  console.error('[canon-codex] SSE disconnected');
2795
2889
  },
2890
+ onReplayExpired: () => observeReconnectRecovery('replay-expired', reconnectRecovery.onReplayExpired()),
2796
2891
  onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
2797
2892
  },
2798
2893
  });
@@ -2811,57 +2906,10 @@ export async function main() {
2811
2906
  catch (error) {
2812
2907
  console.error('[canon-codex] Failed to load startup conversations:', error);
2813
2908
  }
2814
- for (const conversationId of knownConversationIds) {
2815
- try {
2816
- const cursor = loadRuntimeSessionState(runtimeId, {
2817
- conversationId,
2818
- baseCwd: workingDir,
2819
- })?.lastInboundMessageId ?? null;
2820
- const recovered = await collectMissedInboundMessages({
2821
- fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
2822
- cursor,
2823
- agentId,
2824
- });
2825
- if (recovered.mode === 'truncated-window') {
2826
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovery cursor was not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; replaying the bounded recent window`);
2827
- }
2828
- for (const message of recovered.messages) {
2829
- const isPlanReply = isCodexPlanApprovalReply(message.metadata, serviceAgentMode);
2830
- if (!isPlanReply && !shouldTriggerAgentTurn({
2831
- senderType: message.senderType,
2832
- metadata: message.metadata,
2833
- }).allow) {
2834
- persistInboundRecoveryCursorWhenIdle(conversationId, message.id);
2835
- continue;
2836
- }
2837
- if (!claimInboundMessageId(message.id))
2838
- continue;
2839
- try {
2840
- await enqueueInboundMessage({
2841
- conversationId,
2842
- message,
2843
- senderName: message.senderName || message.senderId,
2844
- isOwner: message.senderId === ownerId,
2845
- behavior: recovered.newestPage.behavior,
2846
- activeSelfContextId: recovered.newestPage.activeSelfContextIdByMessageId?.[message.id] ?? null,
2847
- selfContexts: recovered.newestPage.selfContexts,
2848
- hydratedPage: recovered.newestPage,
2849
- });
2850
- settleInboundMessageId(message.id, true);
2851
- }
2852
- catch (error) {
2853
- settleInboundMessageId(message.id, false);
2854
- throw error;
2855
- }
2856
- }
2857
- if (recovered.messages.length > 0) {
2858
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
2859
- }
2860
- }
2861
- catch (error) {
2862
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
2863
- }
2864
- }
2909
+ await reconnectRecovery.recoverNow().catch((error) => {
2910
+ console.error('[canon-codex] Startup recovery failed:', error instanceof Error ? error.message : error);
2911
+ });
2912
+ startupRecoveryComplete = true;
2865
2913
  startCodexStreamInBackground(stream, (error) => {
2866
2914
  console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
2867
2915
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.26.2",
3
+ "version": "0.28.0",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "scripts"
22
22
  ],
23
23
  "scripts": {
24
- "prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk ../coding-agent-host ../rich-cards",
24
+ "prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk ../agent-tools ../coding-agent-host ../rich-cards",
25
25
  "build": "npm run prepare:workspace-deps && node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
26
26
  "dev": "npm run prepare:workspace-deps && tsc --watch",
27
27
  "smoke": "node scripts/smoke-test.mjs",
@@ -29,10 +29,11 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@canonmsg/agent-sdk": "^8.3.0",
33
- "@canonmsg/coding-agent-host": "^0.5.0",
34
- "@canonmsg/core": "^10.3.1",
35
- "@canonmsg/rich-cards": "^0.10.0"
32
+ "@canonmsg/agent-sdk": "^9.0.0",
33
+ "@canonmsg/agent-tools": "^0.7.0",
34
+ "@canonmsg/coding-agent-host": "^0.7.0",
35
+ "@canonmsg/core": "^11.0.0",
36
+ "@canonmsg/rich-cards": "^0.10.3"
36
37
  },
37
38
  "engines": {
38
39
  "node": ">=18.0.0"