@canonmsg/codex-plugin 0.26.2 → 0.27.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
@@ -109,6 +109,23 @@ discovery it also offers no model picker and a fixed effort list. It registers
109
109
  no dynamic tools either, so `no_reply` is unavailable there, the same way rich
110
110
  cards are.
111
111
 
112
+ On the app-server transport, an owner-started foreground turn also receives a
113
+ small Canon communication surface: `codex_app.canon_send_to`, contact/request
114
+ and conversation listing, and `codex_app.canon_cancel_contact_request` for the
115
+ agent's own pending DM requests. `canon_send_to` accepts one stable contact
116
+ target and visible text; the host supplies trusted source-turn routing. These
117
+ tools are withheld from non-owner turns, service-agent mode, detached threads,
118
+ and the exec fallback. Contact cards only claim reach-out support when this
119
+ exact surface is mounted.
120
+
121
+ When a reach-out needs target-owner setup or approval, Canon parks the visible
122
+ opener. Terminal request outcomes enter a deduplicated local lifecycle inbox and
123
+ are supplied as trusted context on the next natural owner turn; they never start
124
+ a synthetic model turn. A connected outcome means Canon already delivered the
125
+ opener. After an SSE reconnect or expired replay window, the host refreshes
126
+ memberships, reconciles outbound request phases, and recovers unseen messages
127
+ from its persisted cursors before resuming live delivery.
128
+
112
129
  Set `CANON_CODEX_TRANSPORT=exec` or `CANON_CODEX_TRANSPORT=app-server` to skip
113
130
  the probe when it misfires.
114
131
 
@@ -638,7 +638,13 @@ function isForegroundCanonToolCall(params) {
638
638
  const tool = rawTool.startsWith('codex_app.')
639
639
  ? rawTool.slice('codex_app.'.length)
640
640
  : rawTool;
641
- return tool === 'canon_runtime_control' || tool === 'no_reply';
641
+ return tool === 'canon_runtime_control'
642
+ || tool === 'canon_send_to'
643
+ || tool === 'canon_list_contacts'
644
+ || tool === 'canon_list_contact_requests'
645
+ || tool === 'canon_list_conversations'
646
+ || tool === 'canon_cancel_contact_request'
647
+ || tool === 'no_reply';
642
648
  }
643
649
  function parseJson(line) {
644
650
  try {
@@ -1,4 +1,5 @@
1
- import { type NoReplyReportClient } from '@canonmsg/core';
1
+ import { type CanonClient, type CanonVerbName, type NoReplyReportClient } from '@canonmsg/core';
2
+ import { type OwnerBoundCanonContactTarget } from '@canonmsg/agent-tools';
2
3
  type JsonRecord = Record<string, unknown>;
3
4
  interface DynamicToolSpec {
4
5
  [key: string]: unknown;
@@ -44,6 +45,11 @@ type DynamicToolCallResponse = {
44
45
  */
45
46
  export declare const CODEX_NO_REPLY_TOOL_NAME = "no_reply";
46
47
  export declare const CANON_RUNTIME_CONTROL_TOOL_NAME = "canon_runtime_control";
48
+ export declare const CODEX_CANON_SEND_TO_TOOL_NAME = "canon_send_to";
49
+ export declare const CODEX_CANON_LIST_CONTACTS_TOOL_NAME = "canon_list_contacts";
50
+ export declare const CODEX_CANON_LIST_CONTACT_REQUESTS_TOOL_NAME = "canon_list_contact_requests";
51
+ export declare const CODEX_CANON_LIST_CONVERSATIONS_TOOL_NAME = "canon_list_conversations";
52
+ export declare const CODEX_CANON_CANCEL_CONTACT_REQUEST_TOOL_NAME = "canon_cancel_contact_request";
47
53
  /**
48
54
  * What the model actually sees for the tool above, for prompt text that names
49
55
  * it (the group posture cue). Only meaningful on the app-server transport —
@@ -65,7 +71,26 @@ export declare function successfulCodexAppToolResult(payload: unknown): DynamicT
65
71
  /** True when this dynamic-tool call is the deliberate-silence verb. */
66
72
  export declare function isCodexNoReplyToolCall(params: CodexAppToolCallParams): boolean;
67
73
  export declare function isCanonRuntimeControlToolCall(params: CodexAppToolCallParams): boolean;
74
+ export declare function codexCanonVerbForToolCall(params: CodexAppToolCallParams): CanonVerbName | null;
75
+ export declare function isCodexCanonVerbToolCall(params: CodexAppToolCallParams): boolean;
68
76
  export declare function isCodexServiceAgentToolCall(params: CodexAppToolCallParams): boolean;
77
+ export interface CodexCanonVerbTrustedContext {
78
+ conversationId: string;
79
+ sourceMessageId: string | null;
80
+ turnId: string | null;
81
+ replyContactTarget?: OwnerBoundCanonContactTarget;
82
+ }
83
+ /**
84
+ * Execute the deliberately filtered canonical-verb projection. The model can
85
+ * author only the destination and visible copy. Provenance and turn metadata
86
+ * come exclusively from the active host turn, so a prompt cannot forge its
87
+ * source or make an outbound message look like an unrelated event.
88
+ */
89
+ export declare function answerCodexCanonVerb(input: {
90
+ client: CanonClient;
91
+ params: CodexAppToolCallParams;
92
+ context: CodexCanonVerbTrustedContext;
93
+ }): Promise<DynamicToolCallResponse>;
69
94
  export interface CanonRuntimeControlRequest {
70
95
  action: 'send_card' | 'request_card' | 'request_input';
71
96
  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 { createOwnerBoundCanonCommunicationBinding, } from '@canonmsg/agent-tools';
2
3
  const emptyObjectSchema = {
3
4
  type: 'object',
4
5
  properties: {},
@@ -90,6 +91,21 @@ 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_CANON_SEND_TO_TOOL_NAME = 'canon_send_to';
95
+ export const CODEX_CANON_LIST_CONTACTS_TOOL_NAME = 'canon_list_contacts';
96
+ export const CODEX_CANON_LIST_CONTACT_REQUESTS_TOOL_NAME = 'canon_list_contact_requests';
97
+ export const CODEX_CANON_LIST_CONVERSATIONS_TOOL_NAME = 'canon_list_conversations';
98
+ export const CODEX_CANON_CANCEL_CONTACT_REQUEST_TOOL_NAME = 'canon_cancel_contact_request';
99
+ const CODEX_CANON_COMMUNICATION_BINDING = createOwnerBoundCanonCommunicationBinding({
100
+ toolNames: {
101
+ send_to: CODEX_CANON_SEND_TO_TOOL_NAME,
102
+ list_contacts: CODEX_CANON_LIST_CONTACTS_TOOL_NAME,
103
+ list_contact_requests: CODEX_CANON_LIST_CONTACT_REQUESTS_TOOL_NAME,
104
+ list_conversations: CODEX_CANON_LIST_CONVERSATIONS_TOOL_NAME,
105
+ cancel_contact_request: CODEX_CANON_CANCEL_CONTACT_REQUEST_TOOL_NAME,
106
+ },
107
+ });
108
+ const CODEX_CANON_FOREGROUND_TOOL_NAMES = new Set(CODEX_CANON_COMMUNICATION_BINDING.tools.map((definition) => definition.name));
93
109
  /**
94
110
  * What the model actually sees for the tool above, for prompt text that names
95
111
  * it (the group posture cue). Only meaningful on the app-server transport —
@@ -121,6 +137,7 @@ const CANON_RUNTIME_CONTROL_TOOL = tool(CANON_RUNTIME_CONTROL_TOOL_NAME, 'Intera
121
137
  },
122
138
  required: ['action'],
123
139
  }, false);
140
+ const CODEX_CANON_COMMUNICATION_TOOLS = CODEX_CANON_COMMUNICATION_BINDING.tools.map((definition) => tool(definition.name, definition.description, definition.inputSchema, false));
124
141
  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
142
  + 'chats when you have nothing to add — no message is created, so no other '
126
143
  + 'member or agent is triggered. Optional private reason (logged, never '
@@ -244,6 +261,7 @@ export const CODEX_APP_DYNAMIC_TOOLS = [
244
261
  },
245
262
  required: ['threadId', 'title'],
246
263
  }),
264
+ ...CODEX_CANON_COMMUNICATION_TOOLS,
247
265
  CANON_RUNTIME_CONTROL_TOOL,
248
266
  // Canon's `no_reply` verb, projected into the only model-visible tool surface
249
267
  // the Codex transport gives us. It is answered by the Canon host itself.
@@ -260,7 +278,8 @@ export const CODEX_SERVICE_AGENT_DYNAMIC_TOOLS = [
260
278
  * human message; a child thread has neither that turn nor its responder.
261
279
  */
262
280
  export const CODEX_DETACHED_THREAD_DYNAMIC_TOOLS = CODEX_APP_DYNAMIC_TOOLS.filter((entry) => (entry.name !== CANON_RUNTIME_CONTROL_TOOL_NAME
263
- && entry.name !== CODEX_NO_REPLY_TOOL_NAME));
281
+ && entry.name !== CODEX_NO_REPLY_TOOL_NAME
282
+ && !CODEX_CANON_FOREGROUND_TOOL_NAMES.has(entry.name)));
264
283
  const CODEX_APP_TOOL_NAMES = new Set(CODEX_APP_DYNAMIC_TOOLS.map((entry) => String(entry.name)));
265
284
  const UNSUPPORTED_TOOLS = new Map([
266
285
  ['automation_update', 'Canon does not manage Codex Desktop automations.'],
@@ -294,10 +313,53 @@ export function isCodexNoReplyToolCall(params) {
294
313
  export function isCanonRuntimeControlToolCall(params) {
295
314
  return normalizeToolName(params.tool) === CANON_RUNTIME_CONTROL_TOOL_NAME;
296
315
  }
316
+ export function codexCanonVerbForToolCall(params) {
317
+ const toolName = normalizeToolName(params.tool);
318
+ return toolName ? CODEX_CANON_COMMUNICATION_BINDING.verbForToolName(toolName) : null;
319
+ }
320
+ export function isCodexCanonVerbToolCall(params) {
321
+ return codexCanonVerbForToolCall(params) !== null;
322
+ }
297
323
  export function isCodexServiceAgentToolCall(params) {
298
324
  const toolName = normalizeToolName(params.tool);
299
325
  return toolName === CANON_RUNTIME_CONTROL_TOOL_NAME || toolName === CODEX_NO_REPLY_TOOL_NAME;
300
326
  }
327
+ /**
328
+ * Execute the deliberately filtered canonical-verb projection. The model can
329
+ * author only the destination and visible copy. Provenance and turn metadata
330
+ * come exclusively from the active host turn, so a prompt cannot forge its
331
+ * source or make an outbound message look like an unrelated event.
332
+ */
333
+ export async function answerCodexCanonVerb(input) {
334
+ const verb = codexCanonVerbForToolCall(input.params);
335
+ if (!verb)
336
+ return toolResult(false, { error: 'Unsupported Canon verb tool.' });
337
+ try {
338
+ const result = await CODEX_CANON_COMMUNICATION_BINDING.execute({
339
+ client: input.client,
340
+ toolName: normalizeToolName(input.params.tool) ?? '',
341
+ arguments: input.params.arguments,
342
+ context: input.context.sourceMessageId ? {
343
+ isOwnerTurn: true,
344
+ conversationId: input.context.conversationId,
345
+ sourceMessageId: input.context.sourceMessageId,
346
+ ...(input.context.turnId ? { turnId: input.context.turnId } : {}),
347
+ ...(input.context.replyContactTarget
348
+ ? { replyContactTarget: input.context.replyContactTarget }
349
+ : {}),
350
+ } : null,
351
+ });
352
+ return {
353
+ success: result.isError !== true,
354
+ contentItems: result.content.map((item) => ({ type: 'inputText', text: item.text })),
355
+ };
356
+ }
357
+ catch (error) {
358
+ return toolResult(false, {
359
+ error: error instanceof Error ? error.message : String(error),
360
+ });
361
+ }
362
+ }
301
363
  export function parseCanonRuntimeControlRequest(params) {
302
364
  if (!isCanonRuntimeControlToolCall(params))
303
365
  return null;
@@ -386,6 +448,12 @@ export async function handleCodexAppToolCall(runtime, params) {
386
448
  error: 'canon_runtime_control is answered by the Canon host, not the app-tool bridge.',
387
449
  });
388
450
  }
451
+ if (CODEX_CANON_FOREGROUND_TOOL_NAMES.has(toolName)) {
452
+ return toolResult(false, {
453
+ tool: toolName,
454
+ error: `${toolName} is answered by the Canon host, not the app-tool bridge.`,
455
+ });
456
+ }
389
457
  const unsupportedReason = UNSUPPORTED_TOOLS.get(toolName);
390
458
  if (unsupportedReason) {
391
459
  return toolResult(false, { tool: toolName, error: unsupportedReason });
@@ -634,6 +702,21 @@ function parseToolArguments(value) {
634
702
  }
635
703
  return {};
636
704
  }
705
+ function parseToolArgumentsStrict(value) {
706
+ if (value === undefined || value === null || value === '')
707
+ return {};
708
+ if (isRecord(value))
709
+ return value;
710
+ if (typeof value !== 'string' || !value.trim())
711
+ return null;
712
+ try {
713
+ const parsed = JSON.parse(value);
714
+ return isRecord(parsed) ? parsed : null;
715
+ }
716
+ catch {
717
+ return null;
718
+ }
719
+ }
637
720
  function normalizeToolName(value) {
638
721
  if (typeof value !== 'string' || !value.trim())
639
722
  return null;
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"];
@@ -69,6 +70,7 @@ export declare function buildCodexRuntimeDescriptor(input: {
69
70
  supportsPlanMode: boolean;
70
71
  supportsCompact?: boolean;
71
72
  supportsRichCards?: boolean;
73
+ supportsCanonCommunicationTools?: boolean;
72
74
  skills?: ReadonlyArray<CodexSkillMetadata>;
73
75
  serviceAgentMode?: boolean;
74
76
  }): CanonRuntimeDescriptor;
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';
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';
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
+ 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, HOST_ADMISSION_ACTIONS_DISABLED, 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, formatPendingContactLifecycleContext, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, readLocalRuntimeEntry, reconcileContactLifecycleEvents, recordLocalRuntimeContactLifecycleEvent, saveLocalRuntimeContactLifecycleCursor, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, shouldTriggerAgentTurn, takeLocalRuntimeContactLifecycleEvents, 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, answerCodexCanonVerb, answerCodexNoReply, classifyCodexAppToolRequest, deniedCodexAppToolResult, handleCodexAppToolCall, isCanonRuntimeControlToolCall, isCodexAppToolCall, isCodexCanonVerbToolCall, 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';
@@ -93,6 +93,9 @@ export function buildCodexServiceSnapshotConfig(input) {
93
93
  };
94
94
  }
95
95
  const MAX_SESSIONS = 12;
96
+ export function createCodexRecoveryCheckpointTracker(persist) {
97
+ return createRecoveryCheckpointTracker(persist);
98
+ }
96
99
  // IDLE_TIMEOUT_MS (30 minutes) is shared with the other coding-agent hosts.
97
100
  const HEARTBEAT_MS = 30_000;
98
101
  /** How Codex says it finished the work but Canon would not take the answer. */
@@ -207,6 +210,9 @@ export function buildCodexRuntimeDescriptor(input) {
207
210
  effortLiveBehavior: 'next_turn',
208
211
  presentation: input.presentation,
209
212
  streamingTextMode: 'snapshot',
213
+ admissionActions: input.supportsCanonCommunicationTools && !serviceAgentMode
214
+ ? { ...HOST_ADMISSION_ACTIONS_DISABLED, requestContact: true, reachOut: true }
215
+ : HOST_ADMISSION_ACTIONS_DISABLED,
210
216
  ...(input.supportsPlanMode && !serviceAgentMode
211
217
  ? {
212
218
  turnModes: [
@@ -278,12 +284,13 @@ export function getCodexRequestingUserId(message) {
278
284
  async function publishAgentRuntime(agentId, runtime, rtdb) {
279
285
  await publishHostAgentRuntime(agentId, 'codex', runtime, rtdb);
280
286
  }
281
- async function loadSessionConfig(conversationId, agentId, rtdb) {
287
+ async function loadSessionConfig(conversationId, agentId, rtdb, retryMissing = true) {
282
288
  return loadHostSessionConfig({
283
289
  conversationId,
284
290
  agentId,
285
291
  rtdb,
286
292
  extraStringFields: CODEX_SESSION_CONFIG_FIELDS,
293
+ retryMissingMs: retryMissing ? 3_000 : 0,
287
294
  });
288
295
  }
289
296
  export function resolveSessionExecutionMode(config, serviceAgentMode = false) {
@@ -370,8 +377,8 @@ function buildCanonPrompt(input) {
370
377
  message: input.message,
371
378
  })), input.noReplyToolName ? { noReplyToolName: input.noReplyToolName } : {});
372
379
  }
373
- function renderInboundContent(message, materialized) {
374
- return renderCanonHostInboundContent(message, materialized);
380
+ function renderInboundContent(message, materialized, options) {
381
+ return renderCanonHostInboundContent(message, materialized, options);
375
382
  }
376
383
  async function materializePromptReplyContext(input) {
377
384
  if (!input.replyContext?.found || !input.replyContext.attachments?.length) {
@@ -388,6 +395,16 @@ async function materializePromptReplyContext(input) {
388
395
  return { replyContext: input.replyContext, materialized: [] };
389
396
  }
390
397
  }
398
+ function ownerBoundReplyContactTarget(replyContext) {
399
+ const card = replyContext?.found ? replyContext.contactCard : undefined;
400
+ if (!card?.userId)
401
+ return undefined;
402
+ return {
403
+ targetUserId: card.userId,
404
+ ...(card.canonContactId ? { canonContactId: card.canonContactId } : {}),
405
+ sourceCardMessageId: replyContext.messageId,
406
+ };
407
+ }
391
408
  function summarizeCommand(command) {
392
409
  const trimmed = command.trim();
393
410
  if (!trimmed)
@@ -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();
@@ -902,6 +927,7 @@ export async function main() {
902
927
  ownerName,
903
928
  membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
904
929
  groupContextMode: getGroupContextMode(input.conversationId, conversation),
930
+ renderOptions: input.renderOptions,
905
931
  });
906
932
  }
907
933
  function writeState(session) {
@@ -964,27 +990,20 @@ export async function main() {
964
990
  }
965
991
  function persistInboundRecoveryCursor(conversationId, messageId) {
966
992
  if (!messageId)
967
- return;
993
+ return true;
968
994
  try {
969
995
  saveRuntimeSessionState(runtimeId, {
970
996
  conversationId,
971
997
  baseCwd: workingDir,
972
998
  lastInboundMessageId: messageId,
973
999
  });
1000
+ return true;
974
1001
  }
975
1002
  catch (error) {
976
1003
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
1004
+ return false;
977
1005
  }
978
1006
  }
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
1007
  async function markQueuedPromptsRejected(conversationId, prompts) {
989
1008
  await Promise.all(prompts.map((prompt) => {
990
1009
  if (!prompt.markAccepted || !prompt.sourceMessageId)
@@ -992,27 +1011,21 @@ export async function main() {
992
1011
  return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
993
1012
  }));
994
1013
  }
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);
1014
+ function settleRejectedPromptCheckpoints(conversationId, prompts) {
1015
+ const checkpoints = recoveryCheckpointsFor(conversationId);
1016
+ for (const prompt of prompts)
1017
+ checkpoints.settle(prompt.sourceMessageId);
1006
1018
  }
1007
1019
  function removeQueuedPrompt(conversationId, sourceMessageId) {
1008
1020
  const session = sessions.get(conversationId);
1009
1021
  if (!session || session.queue.length === 0)
1010
1022
  return;
1011
- const before = session.queue.length;
1023
+ const removed = session.queue.filter((prompt) => prompt.sourceMessageId === sourceMessageId);
1024
+ if (removed.length === 0)
1025
+ return;
1012
1026
  session.queue = session.queue.filter((prompt) => prompt.sourceMessageId !== sourceMessageId);
1013
- if (session.queue.length !== before) {
1014
- writeTurn(session);
1015
- }
1027
+ settleRejectedPromptCheckpoints(conversationId, removed);
1028
+ writeTurn(session);
1016
1029
  }
1017
1030
  function clearStreaming(conversationId) {
1018
1031
  runtimeState.clearStreaming(conversationId).catch(() => { });
@@ -1196,7 +1209,7 @@ export async function main() {
1196
1209
  session.resetRequested = true;
1197
1210
  const droppedPrompts = session.queue.splice(0);
1198
1211
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
1199
- rememberDroppedRecoveryCursor(session, droppedPrompts);
1212
+ settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
1200
1213
  clearStoredThreadId(runtimeId, conversationId, session.environment.baseCwd, session.environment.mode);
1201
1214
  session.adapter.clearThreadId();
1202
1215
  session.activeSelfContextId = null;
@@ -1212,6 +1225,7 @@ export async function main() {
1212
1225
  session.currentTurnOpenedAt = null;
1213
1226
  session.currentTurnUpdatedAt = null;
1214
1227
  session.currentTurnCanUseCodexAppTools = false;
1228
+ session.currentTurnCanUseCodexCanonTools = false;
1215
1229
  session.currentTurnSilenced = false;
1216
1230
  session.lastAcceptedIntent = null;
1217
1231
  session.resetRequested = false;
@@ -1249,7 +1263,7 @@ export async function main() {
1249
1263
  evictOldestIdle();
1250
1264
  }
1251
1265
  const creation = (async () => {
1252
- const config = resolveCodexSessionConfig(await loadSessionConfig(conversationId, agentId, rtdb), serviceAgentMode);
1266
+ const config = resolveCodexSessionConfig(await loadSessionConfig(conversationId, agentId, rtdb, !serviceAgentMode), serviceAgentMode);
1253
1267
  const sessionExecutionMode = resolveSessionExecutionMode(config, serviceAgentMode);
1254
1268
  const workspaceCwd = resolveWorkspaceCwd(config, serviceAgentMode);
1255
1269
  const environment = prepareConversationEnvironment({
@@ -1324,6 +1338,8 @@ export async function main() {
1324
1338
  currentTurnOpenedAt: null,
1325
1339
  currentTurnUpdatedAt: null,
1326
1340
  currentTurnCanUseCodexAppTools: false,
1341
+ currentTurnCanUseCodexCanonTools: false,
1342
+ currentTurnReplyContactTarget: null,
1327
1343
  currentTurnAbortController: null,
1328
1344
  // Corrected by the first turn that runs; a session with no turn
1329
1345
  // publishes nothing anyway, and quiet is never an accident.
@@ -1331,7 +1347,6 @@ export async function main() {
1331
1347
  currentTurnSilenced: false,
1332
1348
  activeSelfContextId: null,
1333
1349
  lastAcceptedIntent: null,
1334
- pendingDroppedRecoveryCursor: null,
1335
1350
  resetRequested: false,
1336
1351
  lastActivity: Date.now(),
1337
1352
  typingKeepaliveTimer: null,
@@ -1371,9 +1386,10 @@ export async function main() {
1371
1386
  planMode,
1372
1387
  artifactRoutingMode: turn.artifactRoutingMode ?? 'disabled',
1373
1388
  canUseCodexAppTools: turn.canUseCodexAppTools ?? false,
1389
+ canUseCodexCanonTools: turn.canUseCodexCanonTools ?? false,
1374
1390
  ...(turn.turnVerbosity ? { turnVerbosity: turn.turnVerbosity } : {}),
1375
1391
  requestingUserId: turn.requestingUserId ?? null,
1376
- recoverySequence: ++inboundRecoverySequence,
1392
+ ...(turn.replyContactTarget ? { replyContactTarget: turn.replyContactTarget } : {}),
1377
1393
  };
1378
1394
  if (toFront) {
1379
1395
  session.queue.unshift(nextPrompt);
@@ -1404,6 +1420,38 @@ export async function main() {
1404
1420
  requestingUserId: responseUserId,
1405
1421
  });
1406
1422
  }
1423
+ function recordContactLifecycleEvent(request) {
1424
+ if (request.requesterId !== agentId || !request.sourceConversationId)
1425
+ return false;
1426
+ return recordLocalRuntimeContactLifecycleEvent(runtimeId, request);
1427
+ }
1428
+ async function reconcileContactLifecycleInbox() {
1429
+ let cursor = readLocalRuntimeEntry(runtimeId)?.contactLifecycleCursor ?? null;
1430
+ let recorded = 0;
1431
+ let duplicate = 0;
1432
+ let ignored = 0;
1433
+ for (let pageNumber = 0; pageNumber < 10; pageNumber += 1) {
1434
+ const page = await client.listContactRequestLifecyclePage({
1435
+ cursor,
1436
+ limit: 100,
1437
+ });
1438
+ const result = await reconcileContactLifecycleEvents({
1439
+ requests: page.requests,
1440
+ requesterId: agentId,
1441
+ record: (request) => recordLocalRuntimeContactLifecycleEvent(runtimeId, request),
1442
+ });
1443
+ recorded += result.recorded;
1444
+ duplicate += result.duplicate;
1445
+ ignored += result.ignored;
1446
+ cursor = page.nextCursor;
1447
+ saveLocalRuntimeContactLifecycleCursor(runtimeId, cursor);
1448
+ if (!page.hasMore)
1449
+ break;
1450
+ }
1451
+ if (recorded > 0) {
1452
+ console.error(`[canon-codex] Contact lifecycle recovery: recorded=${recorded} duplicate=${duplicate} ignored=${ignored}`);
1453
+ }
1454
+ }
1407
1455
  function resolveArtifactRoutingMode(participantContext) {
1408
1456
  return participantContext.conversationType === 'direct' && participantContext.isOwner
1409
1457
  ? 'workspace-generated'
@@ -1415,15 +1463,19 @@ export async function main() {
1415
1463
  * so a prompt that waits behind another still runs under the answer it
1416
1464
  * arrived with.
1417
1465
  */
1418
- function resolveCodexTurnModes(participantContext, message) {
1466
+ function resolveCodexTurnModes(participantContext, message, replyContext) {
1419
1467
  return {
1420
1468
  artifactRoutingMode: resolveArtifactRoutingMode(participantContext),
1421
1469
  canUseCodexAppTools: participantContext.isOwner || serviceAgentMode,
1470
+ canUseCodexCanonTools: participantContext.isOwner && !serviceAgentMode,
1422
1471
  turnVerbosity: resolveTurnVerbosity({
1423
1472
  configured: configuredTurnVerbosity,
1424
1473
  conversationType: participantContext.conversationType,
1425
1474
  }),
1426
1475
  requestingUserId: getCodexRequestingUserId(message),
1476
+ ...(participantContext.isOwner && replyContext
1477
+ ? { replyContactTarget: ownerBoundReplyContactTarget(replyContext) }
1478
+ : {}),
1427
1479
  };
1428
1480
  }
1429
1481
  function runtimeCardRequestPayload(method, params) {
@@ -1465,6 +1517,23 @@ export async function main() {
1465
1517
  if (disposition === 'denied-non-owner') {
1466
1518
  return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
1467
1519
  }
1520
+ if (isCodexCanonVerbToolCall(params)) {
1521
+ if (!session.currentTurnCanUseCodexCanonTools) {
1522
+ return deniedCodexAppToolResult('Canon contact and conversation tools require a real owner-authored inbound turn.');
1523
+ }
1524
+ return await answerCodexCanonVerb({
1525
+ client,
1526
+ params,
1527
+ context: {
1528
+ conversationId: session.conversationId,
1529
+ sourceMessageId,
1530
+ turnId: session.currentTurnId,
1531
+ ...(session.currentTurnReplyContactTarget
1532
+ ? { replyContactTarget: session.currentTurnReplyContactTarget }
1533
+ : {}),
1534
+ },
1535
+ });
1536
+ }
1468
1537
  if (isCanonRuntimeControlToolCall(params)) {
1469
1538
  const command = parseCanonRuntimeControlRequest(params);
1470
1539
  if (!command) {
@@ -1759,7 +1828,7 @@ export async function main() {
1759
1828
  knownConversationIds.add(input.conversationId);
1760
1829
  if (input.turnDispatch && input.turnDispatch.kind !== 'run_turn') {
1761
1830
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed server-dispatched turn: ${input.turnDispatch.reason}`);
1762
- persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1831
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1763
1832
  return;
1764
1833
  }
1765
1834
  if (input.message.metadata?.type === 'plan_approval_reply') {
@@ -1767,7 +1836,7 @@ export async function main() {
1767
1836
  // A service agent never advertises or enters plan mode. Consume stale
1768
1837
  // replies left by an older coding descriptor instead of turning them
1769
1838
  // into hidden plan/implementation prompts after an upgrade or restart.
1770
- persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1839
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1771
1840
  return;
1772
1841
  }
1773
1842
  const planId = readString(input.message.metadata, 'planId');
@@ -1775,7 +1844,7 @@ export async function main() {
1775
1844
  senderId: input.message.senderId,
1776
1845
  metadata: input.message.metadata,
1777
1846
  })) {
1778
- persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1847
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1779
1848
  return;
1780
1849
  }
1781
1850
  const session = await getOrCreateSession(input.conversationId);
@@ -1804,7 +1873,8 @@ export async function main() {
1804
1873
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
1805
1874
  }
1806
1875
  }
1807
- const renderedContent = renderInboundContent(input.message, materialized);
1876
+ const renderOptions = resolveInboundContentRenderOptions(input.isOwner);
1877
+ const renderedContent = renderInboundContent(input.message, materialized, renderOptions);
1808
1878
  const turnMetadata = normalizeTurnMetadata(input.message.metadata);
1809
1879
  const requestedPlanMode = turnMetadata?.requestedTurnMode === 'plan';
1810
1880
  const planCommand = resolveCodexPlanCommand({
@@ -1823,6 +1893,7 @@ export async function main() {
1823
1893
  selfContexts: input.selfContexts,
1824
1894
  provenance: input.provenance,
1825
1895
  hydratedPage: input.hydratedPage,
1896
+ renderOptions,
1826
1897
  });
1827
1898
  const behavior = input.behavior ?? hydrated.behavior;
1828
1899
  const activeSelfContextId = hydrated.activeSelfContextId;
@@ -1849,7 +1920,7 @@ export async function main() {
1849
1920
  : decideAutoReply(participantContext, behavior);
1850
1921
  if (!autoReply.allow) {
1851
1922
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
1852
- persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1923
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1853
1924
  return;
1854
1925
  }
1855
1926
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
@@ -1873,11 +1944,11 @@ export async function main() {
1873
1944
  replyBehavior: 'suppress_auto_reply',
1874
1945
  },
1875
1946
  }).catch(() => { });
1876
- persistInboundRecoveryCursor(input.conversationId, input.message.id);
1947
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1877
1948
  return;
1878
1949
  }
1879
1950
  session.activeSelfContextId = activeSelfContextId;
1880
- const prompt = buildCanonPrompt({
1951
+ const basePrompt = buildCanonPrompt({
1881
1952
  content,
1882
1953
  conversationId: input.conversationId,
1883
1954
  participantContext,
@@ -1889,8 +1960,12 @@ export async function main() {
1889
1960
  message: input.message,
1890
1961
  ...(useAppServer ? { noReplyToolName: CODEX_NO_REPLY_MODEL_TOOL_NAME } : {}),
1891
1962
  });
1963
+ const lifecycleContext = input.isOwner
1964
+ ? formatPendingContactLifecycleContext(takeLocalRuntimeContactLifecycleEvents(runtimeId, input.conversationId))
1965
+ : null;
1966
+ const prompt = lifecycleContext ? `${basePrompt}\n\n${lifecycleContext}` : basePrompt;
1892
1967
  if (session.running && deliveryIntent === 'interrupt') {
1893
- enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message));
1968
+ enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message, replyContext));
1894
1969
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
1895
1970
  session.currentTurnAbortController?.abort(new Error('Codex turn interrupted by a newer message'));
1896
1971
  await session.adapter.interrupt().catch(() => { });
@@ -1898,7 +1973,7 @@ export async function main() {
1898
1973
  typingSignals.clear(input.conversationId).catch(() => { });
1899
1974
  return;
1900
1975
  }
1901
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message));
1976
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message, replyContext));
1902
1977
  }
1903
1978
  function sendTurnArtifactFile(session, file) {
1904
1979
  return sendMediaFileMessage(client, session.conversationId, file.path, '', {
@@ -1929,6 +2004,8 @@ export async function main() {
1929
2004
  session.currentTurnOpenedAt = Date.now();
1930
2005
  session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
1931
2006
  session.currentTurnCanUseCodexAppTools = nextTurn.canUseCodexAppTools === true;
2007
+ session.currentTurnCanUseCodexCanonTools = nextTurn.canUseCodexCanonTools === true;
2008
+ session.currentTurnReplyContactTarget = nextTurn.replyContactTarget ?? null;
1932
2009
  session.currentTurnAbortController = new AbortController();
1933
2010
  // A continuation prompt (a plan-review result) carries none, and keeps the
1934
2011
  // conversation's last answer rather than silently reverting to verbose.
@@ -2333,11 +2410,7 @@ export async function main() {
2333
2410
  finally {
2334
2411
  session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
2335
2412
  session.currentTurnAbortController = null;
2336
- persistInboundRecoveryCursor(session.conversationId, nextTurn.sourceMessageId);
2337
- if (session.pendingDroppedRecoveryCursor) {
2338
- persistInboundRecoveryCursor(session.conversationId, session.pendingDroppedRecoveryCursor);
2339
- session.pendingDroppedRecoveryCursor = null;
2340
- }
2413
+ recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
2341
2414
  stopVisibleWorkSignal(session);
2342
2415
  session.running = false;
2343
2416
  session.state.state = 'idle';
@@ -2346,6 +2419,8 @@ export async function main() {
2346
2419
  session.currentTurnOpenedAt = null;
2347
2420
  session.currentTurnUpdatedAt = null;
2348
2421
  session.currentTurnCanUseCodexAppTools = false;
2422
+ session.currentTurnCanUseCodexCanonTools = false;
2423
+ session.currentTurnReplyContactTarget = null;
2349
2424
  session.currentTurnSilenced = false;
2350
2425
  session.lastAcceptedIntent = null;
2351
2426
  session.resetRequested = false;
@@ -2429,11 +2504,25 @@ export async function main() {
2429
2504
  supportsPlanMode: useAppServer,
2430
2505
  supportsCompact: useAppServer,
2431
2506
  supportsRichCards: useAppServer,
2507
+ supportsCanonCommunicationTools: useAppServer,
2432
2508
  skills: codexSkills,
2433
2509
  serviceAgentMode,
2434
2510
  }),
2435
2511
  });
2436
2512
  let runtimeDescriptor = buildCurrentRuntimeDescriptor();
2513
+ const resolveInboundContentRenderOptions = (isOwnerTurn) => {
2514
+ const descriptorActions = runtimeDescriptor.runtimeDescriptor?.admissionActions
2515
+ ?? HOST_ADMISSION_ACTIONS_DISABLED;
2516
+ const admissionActions = isOwnerTurn && useAppServer && !serviceAgentMode
2517
+ ? descriptorActions
2518
+ : HOST_ADMISSION_ACTIONS_DISABLED;
2519
+ return {
2520
+ admissionActions,
2521
+ ...(admissionActions.reachOut
2522
+ ? { reachOutToolName: 'codex_app.canon_send_to' }
2523
+ : {}),
2524
+ };
2525
+ };
2437
2526
  async function refreshCodexSkillInventory(forceReload = false) {
2438
2527
  if (!useAppServer)
2439
2528
  return;
@@ -2550,7 +2639,7 @@ export async function main() {
2550
2639
  if (type === 'stop_and_drop') {
2551
2640
  const droppedPrompts = session.queue.splice(0);
2552
2641
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
2553
- rememberDroppedRecoveryCursor(session, droppedPrompts);
2642
+ settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
2554
2643
  }
2555
2644
  if (session.running) {
2556
2645
  session.currentTurnAbortController?.abort(new Error(`Codex turn interrupted by ${type}`));
@@ -2745,6 +2834,90 @@ export async function main() {
2745
2834
  publishRuntimeDetailsInFlight = false;
2746
2835
  }
2747
2836
  };
2837
+ let startupRecoveryComplete = false;
2838
+ async function recoverInboundMessageGaps() {
2839
+ // A reconnect can reveal conversations created while this host was
2840
+ // offline. Refresh before sweeping cursors; this is one REST reconciliation
2841
+ // pass, not an automatic retry loop.
2842
+ const knownBeforeRefresh = new Set(knownConversationIds);
2843
+ await refreshKnownConversationIds(true);
2844
+ const conversationsDiscoveredWhileOffline = startupRecoveryComplete
2845
+ ? new Set([...knownConversationIds].filter((id) => !knownBeforeRefresh.has(id)))
2846
+ : new Set();
2847
+ try {
2848
+ await reconcileContactLifecycleInbox();
2849
+ }
2850
+ catch (error) {
2851
+ console.error('[canon-codex] Contact lifecycle recovery failed:', error instanceof Error ? error.message : error);
2852
+ }
2853
+ for (const conversationId of knownConversationIds) {
2854
+ const recoveryCheckpoints = recoveryCheckpointsFor(conversationId);
2855
+ const recoveryBatch = recoveryCheckpoints.reserveBatch();
2856
+ try {
2857
+ const cursor = loadRuntimeSessionState(runtimeId, {
2858
+ conversationId,
2859
+ baseCwd: workingDir,
2860
+ })?.lastInboundMessageId ?? null;
2861
+ const recovered = await collectMissedInboundMessages({
2862
+ fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
2863
+ cursor,
2864
+ agentId,
2865
+ requireContiguousCursor: true,
2866
+ noCursorMode: conversationsDiscoveredWhileOffline.has(conversationId)
2867
+ ? 'bounded-window'
2868
+ : 'latest-only',
2869
+ });
2870
+ recoveryBatch.commit(recovered.messages.map((message) => message.id), recovered.mode === 'incomplete-gap' ? recovered.recoveryCursor : null);
2871
+ if (recovered.mode === 'incomplete-gap') {
2872
+ 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'}`);
2873
+ }
2874
+ for (const message of recovered.messages) {
2875
+ const isPlanReply = isCodexPlanApprovalReply(message.metadata, serviceAgentMode);
2876
+ if (!isPlanReply && !shouldTriggerAgentTurn({
2877
+ senderType: message.senderType,
2878
+ metadata: message.metadata,
2879
+ }).allow) {
2880
+ recoveryCheckpoints.settle(message.id);
2881
+ continue;
2882
+ }
2883
+ if (!claimInboundMessageId(message.id))
2884
+ continue;
2885
+ try {
2886
+ await enqueueInboundMessage({
2887
+ conversationId,
2888
+ message,
2889
+ senderName: message.senderName || message.senderId,
2890
+ isOwner: message.senderId === ownerId,
2891
+ behavior: recovered.newestPage.behavior,
2892
+ activeSelfContextId: recovered.newestPage.activeSelfContextIdByMessageId?.[message.id] ?? null,
2893
+ selfContexts: recovered.newestPage.selfContexts,
2894
+ hydratedPage: recovered.newestPage,
2895
+ });
2896
+ settleInboundMessageId(message.id, true);
2897
+ }
2898
+ catch (error) {
2899
+ settleInboundMessageId(message.id, false);
2900
+ throw error;
2901
+ }
2902
+ }
2903
+ if (recovered.messages.length > 0) {
2904
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
2905
+ }
2906
+ }
2907
+ catch (error) {
2908
+ recoveryBatch.cancel();
2909
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovery failed:`, error instanceof Error ? error.message : error);
2910
+ }
2911
+ }
2912
+ }
2913
+ const reconnectRecovery = createReconnectRecoveryCoordinator(recoverInboundMessageGaps);
2914
+ const observeReconnectRecovery = (reason, recovery) => {
2915
+ if (!recovery)
2916
+ return;
2917
+ void recovery.catch((error) => {
2918
+ console.error(`[canon-codex] ${reason} recovery failed:`, error instanceof Error ? error.message : error);
2919
+ });
2920
+ };
2748
2921
  const stream = new CanonStream({
2749
2922
  apiKey,
2750
2923
  agentId,
@@ -2756,9 +2929,10 @@ export async function main() {
2756
2929
  return;
2757
2930
  if (!claimInboundMessageId(message.id))
2758
2931
  return;
2932
+ recoveryCheckpointsFor(payload.conversationId).track(message.id);
2759
2933
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2760
2934
  console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2761
- persistInboundRecoveryCursorWhenIdle(payload.conversationId, message.id);
2935
+ recoveryCheckpointsFor(payload.conversationId).settle(message.id);
2762
2936
  settleInboundMessageId(message.id, true);
2763
2937
  return;
2764
2938
  }
@@ -2783,9 +2957,13 @@ export async function main() {
2783
2957
  onConversationUpdated: (payload) => {
2784
2958
  handleConversationUpdated(payload);
2785
2959
  },
2960
+ onContactRequestUpdated: (payload) => {
2961
+ recordContactLifecycleEvent(payload);
2962
+ },
2786
2963
  onConnected: () => {
2787
2964
  streamConnected = true;
2788
2965
  void publishRuntimeHeartbeat();
2966
+ observeReconnectRecovery('reconnect', reconnectRecovery.onConnected());
2789
2967
  console.error('[canon-codex] SSE connected');
2790
2968
  },
2791
2969
  onDisconnected: () => {
@@ -2793,6 +2971,7 @@ export async function main() {
2793
2971
  runtimeState.clearAgentRuntime().catch(() => { });
2794
2972
  console.error('[canon-codex] SSE disconnected');
2795
2973
  },
2974
+ onReplayExpired: () => observeReconnectRecovery('replay-expired', reconnectRecovery.onReplayExpired()),
2796
2975
  onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
2797
2976
  },
2798
2977
  });
@@ -2811,57 +2990,10 @@ export async function main() {
2811
2990
  catch (error) {
2812
2991
  console.error('[canon-codex] Failed to load startup conversations:', error);
2813
2992
  }
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
- }
2993
+ await reconnectRecovery.recoverNow().catch((error) => {
2994
+ console.error('[canon-codex] Startup recovery failed:', error instanceof Error ? error.message : error);
2995
+ });
2996
+ startupRecoveryComplete = true;
2865
2997
  startCodexStreamInBackground(stream, (error) => {
2866
2998
  console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
2867
2999
  });
package/dist/register.js CHANGED
@@ -28,6 +28,7 @@ After approval, start it with CANON_AGENT=<profile> canon-codex --cwd /path/to/p
28
28
  const OPTIONS = {
29
29
  moduleUrl: import.meta.url,
30
30
  clientType: 'codex',
31
+ sessionSetupPolicy: 'runtime_descriptor_required',
31
32
  cliName: 'canon-codex-register',
32
33
  hostBinName: 'canon-codex',
33
34
  developerInfo: 'Codex host plugin',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.26.2",
3
+ "version": "0.27.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,9 +29,10 @@
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",
32
+ "@canonmsg/agent-sdk": "^8.9.0",
33
+ "@canonmsg/agent-tools": "^0.6.0",
34
+ "@canonmsg/coding-agent-host": "^0.6.0",
35
+ "@canonmsg/core": "^10.7.0",
35
36
  "@canonmsg/rich-cards": "^0.10.0"
36
37
  },
37
38
  "engines": {