@canonmsg/codex-plugin 0.26.1 → 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
@@ -79,6 +79,10 @@ model cannot select another user, conversation, or responder. It can display a
79
79
  card with `send_card`, wait for an action card with `request_card`, or ask one
80
80
  standalone question with `request_input`.
81
81
 
82
+ Service-agent sessions use the configured shared workspace and read-only local
83
+ filesystem automatically. They do not ask conversation members to choose a
84
+ coding workspace, execution mode, model, reasoning level, or permission mode.
85
+
82
86
  Authorized conversation members may use a service agent even when they do not
83
87
  own its Canon identity. Normal coding-host sessions keep their existing
84
88
  owner-only execution boundary.
@@ -105,6 +109,23 @@ discovery it also offers no model picker and a fixed effort list. It registers
105
109
  no dynamic tools either, so `no_reply` is unavailable there, the same way rich
106
110
  cards are.
107
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
+
108
129
  Set `CANON_CODEX_TRANSPORT=exec` or `CANON_CODEX_TRANSPORT=app-server` to skip
109
130
  the probe when it misfires.
110
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';
@@ -36,6 +36,20 @@ export declare function buildCodexLiveSessionConfig(input: {
36
36
  permissionMode?: string | undefined;
37
37
  model?: string | undefined;
38
38
  };
39
+ export declare function buildCodexServiceSnapshotConfig(input: {
40
+ model?: string;
41
+ permissionMode?: string;
42
+ effort?: string;
43
+ workspaceId?: string;
44
+ }): {
45
+ model: string | undefined;
46
+ permissionMode: string | undefined;
47
+ effort: string | undefined;
48
+ workspaceId: string | undefined;
49
+ executionMode: "locked";
50
+ executionBranch: null;
51
+ };
52
+ export declare function createCodexRecoveryCheckpointTracker(persist: (messageId: string) => boolean): RecoveryCheckpointTracker;
39
53
  /** Conservative fallback used only when native app-server discovery is unavailable. */
40
54
  export declare const CODEX_EFFORT_OPTIONS: readonly CodexControlOption[];
41
55
  export declare const CODEX_SESSION_CONFIG_FIELDS: readonly ["permissionMode", "effort"];
@@ -56,7 +70,9 @@ export declare function buildCodexRuntimeDescriptor(input: {
56
70
  supportsPlanMode: boolean;
57
71
  supportsCompact?: boolean;
58
72
  supportsRichCards?: boolean;
73
+ supportsCanonCommunicationTools?: boolean;
59
74
  skills?: ReadonlyArray<CodexSkillMetadata>;
75
+ serviceAgentMode?: boolean;
60
76
  }): CanonRuntimeDescriptor;
61
77
  export declare function buildCodexTurnResponseRouting(input: {
62
78
  requestingUserId?: string | null;
@@ -73,6 +89,7 @@ export declare function getCodexRequestingUserId(message: {
73
89
  export declare function resolveSessionExecutionMode(config: {
74
90
  executionMode?: ExecutionEnvironmentMode;
75
91
  } | null | undefined, serviceAgentMode?: boolean): ExecutionEnvironmentMode;
92
+ export declare function resolveCodexSessionConfig<T extends Record<string, unknown>>(config: T | null | undefined, serviceAgentMode?: boolean): T | null;
76
93
  export declare function resolveWorkspaceCwd(config: {
77
94
  workspaceId?: string;
78
95
  retiredWorkspaceConfig?: boolean;
@@ -95,6 +112,19 @@ export declare function resolveCodexEffectiveRuntimePolicy(input: {
95
112
  environment: Pick<PreparedExecutionEnvironment, 'baseCwd' | 'mode'>;
96
113
  serviceAgentMode?: boolean;
97
114
  }): CodexEffectiveRuntimePolicy;
115
+ export declare function resolveCodexPlanCommand(input: {
116
+ content: string;
117
+ requestedPlanMode: boolean;
118
+ useAppServer: boolean;
119
+ serviceAgentMode: boolean;
120
+ }): {
121
+ planMode: boolean;
122
+ content: string;
123
+ };
124
+ export declare function isCodexPlanApprovalReply(metadata: unknown, serviceAgentMode?: boolean): metadata is Record<string, unknown> & {
125
+ type: 'plan_approval_reply';
126
+ decision: string;
127
+ };
98
128
  /**
99
129
  * `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
100
130
  * per-conversation-type default".
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';
@@ -78,7 +78,24 @@ export function buildCodexLiveSessionConfig(input) {
78
78
  executionBranch: input.executionBranch ?? null,
79
79
  };
80
80
  }
81
+ export function buildCodexServiceSnapshotConfig(input) {
82
+ // Keep every setup key present, including keys whose host value is
83
+ // undefined. `publishHostSessionSnapshots` merges this over any persisted
84
+ // coding config, so an absent host value clears rather than resurrects a
85
+ // stale member-selected value.
86
+ return {
87
+ model: input.model,
88
+ permissionMode: input.permissionMode,
89
+ effort: input.effort,
90
+ workspaceId: input.workspaceId,
91
+ executionMode: 'locked',
92
+ executionBranch: null,
93
+ };
94
+ }
81
95
  const MAX_SESSIONS = 12;
96
+ export function createCodexRecoveryCheckpointTracker(persist) {
97
+ return createRecoveryCheckpointTracker(persist);
98
+ }
82
99
  // IDLE_TIMEOUT_MS (30 minutes) is shared with the other coding-agent hosts.
83
100
  const HEARTBEAT_MS = 30_000;
84
101
  /** How Codex says it finished the work but Canon would not take the answer. */
@@ -138,6 +155,7 @@ export function buildCodexSkillCommands(skills) {
138
155
  });
139
156
  }
140
157
  export function buildCodexRuntimeDescriptor(input) {
158
+ const serviceAgentMode = input.serviceAgentMode === true;
141
159
  const commands = [
142
160
  {
143
161
  id: 'runtime-status',
@@ -157,7 +175,7 @@ export function buildCodexRuntimeDescriptor(input) {
157
175
  RUNTIME_STOP_ACTION,
158
176
  RUNTIME_STOP_AND_DROP_ACTION,
159
177
  ];
160
- if (input.supportsCompact) {
178
+ if (input.supportsCompact && !serviceAgentMode) {
161
179
  commands.push({
162
180
  id: 'codex-compact-context',
163
181
  label: 'Compact',
@@ -177,20 +195,25 @@ export function buildCodexRuntimeDescriptor(input) {
177
195
  }
178
196
  const descriptor = buildFirstPartyCodingRuntimeDescriptor({
179
197
  clientType: 'codex',
180
- models: input.models,
181
- workspaces: input.workspaces,
182
- workspaceRoots: input.workspaceRoots,
183
- executionModes: input.executionModes,
184
- permissionModes: input.permissionModes,
185
- defaultPermissionMode: input.defaultPermissionMode,
198
+ models: serviceAgentMode ? [] : input.models,
199
+ workspaces: serviceAgentMode ? [] : input.workspaces,
200
+ workspaceRoots: serviceAgentMode ? undefined : input.workspaceRoots,
201
+ executionModes: serviceAgentMode ? [] : input.executionModes,
202
+ permissionModes: serviceAgentMode ? [] : input.permissionModes,
203
+ defaultPermissionMode: serviceAgentMode ? undefined : input.defaultPermissionMode,
186
204
  permissionModeLabel: 'Execution policy',
187
205
  modelLiveBehavior: 'next_turn',
188
- effortOptions: input.effortOptions ?? [...CODEX_EFFORT_OPTIONS],
189
- defaultEffort: input.defaultEffort ?? 'medium',
206
+ effortOptions: serviceAgentMode
207
+ ? []
208
+ : input.effortOptions ?? [...CODEX_EFFORT_OPTIONS],
209
+ defaultEffort: serviceAgentMode ? undefined : input.defaultEffort ?? 'medium',
190
210
  effortLiveBehavior: 'next_turn',
191
211
  presentation: input.presentation,
192
212
  streamingTextMode: 'snapshot',
193
- ...(input.supportsPlanMode
213
+ admissionActions: input.supportsCanonCommunicationTools && !serviceAgentMode
214
+ ? { ...HOST_ADMISSION_ACTIONS_DISABLED, requestContact: true, reachOut: true }
215
+ : HOST_ADMISSION_ACTIONS_DISABLED,
216
+ ...(input.supportsPlanMode && !serviceAgentMode
194
217
  ? {
195
218
  turnModes: [
196
219
  {
@@ -220,7 +243,7 @@ export function buildCodexRuntimeDescriptor(input) {
220
243
  rich: {
221
244
  schema: 'canon.card.v1',
222
245
  lifecycle: 'blocking_requires_action',
223
- responder: 'agent_owner',
246
+ ...(serviceAgentMode ? {} : { responder: 'agent_owner' }),
224
247
  result: 'action_or_values',
225
248
  maxTimeoutMs: 30 * 60_000,
226
249
  blockKinds: ['summary', 'metricGrid', 'chart', 'table', 'list', 'callout', 'actions'],
@@ -231,6 +254,13 @@ export function buildCodexRuntimeDescriptor(input) {
231
254
  }
232
255
  : {}),
233
256
  });
257
+ if (serviceAgentMode) {
258
+ const { runtimeControls: _runtimeControls, ...serviceDescriptor } = descriptor;
259
+ return {
260
+ ...serviceDescriptor,
261
+ coreControls: [],
262
+ };
263
+ }
234
264
  if (input.models.length > 0) {
235
265
  return descriptor;
236
266
  }
@@ -254,12 +284,13 @@ export function getCodexRequestingUserId(message) {
254
284
  async function publishAgentRuntime(agentId, runtime, rtdb) {
255
285
  await publishHostAgentRuntime(agentId, 'codex', runtime, rtdb);
256
286
  }
257
- async function loadSessionConfig(conversationId, agentId, rtdb) {
287
+ async function loadSessionConfig(conversationId, agentId, rtdb, retryMissing = true) {
258
288
  return loadHostSessionConfig({
259
289
  conversationId,
260
290
  agentId,
261
291
  rtdb,
262
292
  extraStringFields: CODEX_SESSION_CONFIG_FIELDS,
293
+ retryMissingMs: retryMissing ? 3_000 : 0,
263
294
  });
264
295
  }
265
296
  export function resolveSessionExecutionMode(config, serviceAgentMode = false) {
@@ -269,6 +300,9 @@ export function resolveSessionExecutionMode(config, serviceAgentMode = false) {
269
300
  return config.executionMode;
270
301
  throw new ExecutionEnvironmentError('Session config is missing an execution mode.', 'Choose Isolated worktree or Use shared project before starting this coding session.');
271
302
  }
303
+ export function resolveCodexSessionConfig(config, serviceAgentMode = false) {
304
+ return serviceAgentMode ? null : config ?? null;
305
+ }
272
306
  export function resolveWorkspaceCwd(config, serviceAgentMode = false) {
273
307
  return resolveHostWorkspaceCwd({
274
308
  workspaceOptions,
@@ -311,6 +345,9 @@ export function resolveCodexEffectiveRuntimePolicy(input) {
311
345
  baseCwd: input.environment.baseCwd,
312
346
  executionMode: input.environment.mode,
313
347
  permissionMode: permissionMode ?? null,
348
+ ...(input.serviceAgentMode
349
+ ? { serviceAgentMode: true, model: model ?? null }
350
+ : {}),
314
351
  sandbox,
315
352
  // `--ask-for-approval` is rejected at startup, so this was always null.
316
353
  // The key must stay: it is hashed into every persisted thread fingerprint.
@@ -340,8 +377,8 @@ function buildCanonPrompt(input) {
340
377
  message: input.message,
341
378
  })), input.noReplyToolName ? { noReplyToolName: input.noReplyToolName } : {});
342
379
  }
343
- function renderInboundContent(message, materialized) {
344
- return renderCanonHostInboundContent(message, materialized);
380
+ function renderInboundContent(message, materialized, options) {
381
+ return renderCanonHostInboundContent(message, materialized, options);
345
382
  }
346
383
  async function materializePromptReplyContext(input) {
347
384
  if (!input.replyContext?.found || !input.replyContext.attachments?.length) {
@@ -358,6 +395,16 @@ async function materializePromptReplyContext(input) {
358
395
  return { replyContext: input.replyContext, materialized: [] };
359
396
  }
360
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
+ }
361
408
  function summarizeCommand(command) {
362
409
  const trimmed = command.trim();
363
410
  if (!trimmed)
@@ -400,6 +447,14 @@ function parsePlanCommand(content) {
400
447
  content: rest || 'Please inspect the request and propose a plan before making changes.',
401
448
  };
402
449
  }
450
+ export function resolveCodexPlanCommand(input) {
451
+ if (!input.useAppServer || input.serviceAgentMode) {
452
+ return { planMode: false, content: input.content };
453
+ }
454
+ return input.requestedPlanMode
455
+ ? { planMode: true, content: input.content }
456
+ : parsePlanCommand(input.content);
457
+ }
403
458
  function mapCodexQuestions(value) {
404
459
  if (!Array.isArray(value))
405
460
  return undefined;
@@ -453,6 +508,12 @@ function mapCodexQuestions(value) {
453
508
  function isRecord(value) {
454
509
  return Boolean(value && typeof value === 'object' && !Array.isArray(value));
455
510
  }
511
+ export function isCodexPlanApprovalReply(metadata, serviceAgentMode = false) {
512
+ return !serviceAgentMode
513
+ && isRecord(metadata)
514
+ && metadata.type === 'plan_approval_reply'
515
+ && typeof metadata.decision === 'string';
516
+ }
456
517
  function readString(record, key) {
457
518
  const value = record[key];
458
519
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
@@ -772,7 +833,15 @@ export async function main() {
772
833
  });
773
834
  const sessions = new Map();
774
835
  const pendingSessionCreations = new Map();
775
- 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
+ }
776
845
  const conversationCache = new Map();
777
846
  const knownConversationIds = new Set();
778
847
  const promptedGroupContextConversationIds = new Set();
@@ -858,6 +927,7 @@ export async function main() {
858
927
  ownerName,
859
928
  membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
860
929
  groupContextMode: getGroupContextMode(input.conversationId, conversation),
930
+ renderOptions: input.renderOptions,
861
931
  });
862
932
  }
863
933
  function writeState(session) {
@@ -920,27 +990,20 @@ export async function main() {
920
990
  }
921
991
  function persistInboundRecoveryCursor(conversationId, messageId) {
922
992
  if (!messageId)
923
- return;
993
+ return true;
924
994
  try {
925
995
  saveRuntimeSessionState(runtimeId, {
926
996
  conversationId,
927
997
  baseCwd: workingDir,
928
998
  lastInboundMessageId: messageId,
929
999
  });
1000
+ return true;
930
1001
  }
931
1002
  catch (error) {
932
1003
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
1004
+ return false;
933
1005
  }
934
1006
  }
935
- function persistInboundRecoveryCursorWhenIdle(conversationId, messageId) {
936
- const session = sessions.get(conversationId);
937
- // A later queued turn will advance past this non-triggering message. Do
938
- // not write it ahead of earlier work and then let that work regress the
939
- // cursor when it completes.
940
- if (session?.running || (session?.queue.length ?? 0) > 0)
941
- return;
942
- persistInboundRecoveryCursor(conversationId, messageId);
943
- }
944
1007
  async function markQueuedPromptsRejected(conversationId, prompts) {
945
1008
  await Promise.all(prompts.map((prompt) => {
946
1009
  if (!prompt.markAccepted || !prompt.sourceMessageId)
@@ -948,27 +1011,21 @@ export async function main() {
948
1011
  return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
949
1012
  }));
950
1013
  }
951
- function rememberDroppedRecoveryCursor(session, prompts) {
952
- const latest = prompts.reduce((current, prompt) => (prompt.sourceMessageId && (!current || prompt.recoverySequence > current.recoverySequence)
953
- ? prompt
954
- : current), null)?.sourceMessageId;
955
- if (!latest)
956
- return;
957
- if (session.running) {
958
- session.pendingDroppedRecoveryCursor = latest;
959
- return;
960
- }
961
- 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);
962
1018
  }
963
1019
  function removeQueuedPrompt(conversationId, sourceMessageId) {
964
1020
  const session = sessions.get(conversationId);
965
1021
  if (!session || session.queue.length === 0)
966
1022
  return;
967
- const before = session.queue.length;
1023
+ const removed = session.queue.filter((prompt) => prompt.sourceMessageId === sourceMessageId);
1024
+ if (removed.length === 0)
1025
+ return;
968
1026
  session.queue = session.queue.filter((prompt) => prompt.sourceMessageId !== sourceMessageId);
969
- if (session.queue.length !== before) {
970
- writeTurn(session);
971
- }
1027
+ settleRejectedPromptCheckpoints(conversationId, removed);
1028
+ writeTurn(session);
972
1029
  }
973
1030
  function clearStreaming(conversationId) {
974
1031
  runtimeState.clearStreaming(conversationId).catch(() => { });
@@ -1152,7 +1209,7 @@ export async function main() {
1152
1209
  session.resetRequested = true;
1153
1210
  const droppedPrompts = session.queue.splice(0);
1154
1211
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
1155
- rememberDroppedRecoveryCursor(session, droppedPrompts);
1212
+ settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
1156
1213
  clearStoredThreadId(runtimeId, conversationId, session.environment.baseCwd, session.environment.mode);
1157
1214
  session.adapter.clearThreadId();
1158
1215
  session.activeSelfContextId = null;
@@ -1168,6 +1225,7 @@ export async function main() {
1168
1225
  session.currentTurnOpenedAt = null;
1169
1226
  session.currentTurnUpdatedAt = null;
1170
1227
  session.currentTurnCanUseCodexAppTools = false;
1228
+ session.currentTurnCanUseCodexCanonTools = false;
1171
1229
  session.currentTurnSilenced = false;
1172
1230
  session.lastAcceptedIntent = null;
1173
1231
  session.resetRequested = false;
@@ -1205,7 +1263,7 @@ export async function main() {
1205
1263
  evictOldestIdle();
1206
1264
  }
1207
1265
  const creation = (async () => {
1208
- const config = await loadSessionConfig(conversationId, agentId, rtdb);
1266
+ const config = resolveCodexSessionConfig(await loadSessionConfig(conversationId, agentId, rtdb, !serviceAgentMode), serviceAgentMode);
1209
1267
  const sessionExecutionMode = resolveSessionExecutionMode(config, serviceAgentMode);
1210
1268
  const workspaceCwd = resolveWorkspaceCwd(config, serviceAgentMode);
1211
1269
  const environment = prepareConversationEnvironment({
@@ -1280,6 +1338,8 @@ export async function main() {
1280
1338
  currentTurnOpenedAt: null,
1281
1339
  currentTurnUpdatedAt: null,
1282
1340
  currentTurnCanUseCodexAppTools: false,
1341
+ currentTurnCanUseCodexCanonTools: false,
1342
+ currentTurnReplyContactTarget: null,
1283
1343
  currentTurnAbortController: null,
1284
1344
  // Corrected by the first turn that runs; a session with no turn
1285
1345
  // publishes nothing anyway, and quiet is never an accident.
@@ -1287,7 +1347,6 @@ export async function main() {
1287
1347
  currentTurnSilenced: false,
1288
1348
  activeSelfContextId: null,
1289
1349
  lastAcceptedIntent: null,
1290
- pendingDroppedRecoveryCursor: null,
1291
1350
  resetRequested: false,
1292
1351
  lastActivity: Date.now(),
1293
1352
  typingKeepaliveTimer: null,
@@ -1327,9 +1386,10 @@ export async function main() {
1327
1386
  planMode,
1328
1387
  artifactRoutingMode: turn.artifactRoutingMode ?? 'disabled',
1329
1388
  canUseCodexAppTools: turn.canUseCodexAppTools ?? false,
1389
+ canUseCodexCanonTools: turn.canUseCodexCanonTools ?? false,
1330
1390
  ...(turn.turnVerbosity ? { turnVerbosity: turn.turnVerbosity } : {}),
1331
1391
  requestingUserId: turn.requestingUserId ?? null,
1332
- recoverySequence: ++inboundRecoverySequence,
1392
+ ...(turn.replyContactTarget ? { replyContactTarget: turn.replyContactTarget } : {}),
1333
1393
  };
1334
1394
  if (toFront) {
1335
1395
  session.queue.unshift(nextPrompt);
@@ -1360,6 +1420,38 @@ export async function main() {
1360
1420
  requestingUserId: responseUserId,
1361
1421
  });
1362
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
+ }
1363
1455
  function resolveArtifactRoutingMode(participantContext) {
1364
1456
  return participantContext.conversationType === 'direct' && participantContext.isOwner
1365
1457
  ? 'workspace-generated'
@@ -1371,15 +1463,19 @@ export async function main() {
1371
1463
  * so a prompt that waits behind another still runs under the answer it
1372
1464
  * arrived with.
1373
1465
  */
1374
- function resolveCodexTurnModes(participantContext, message) {
1466
+ function resolveCodexTurnModes(participantContext, message, replyContext) {
1375
1467
  return {
1376
1468
  artifactRoutingMode: resolveArtifactRoutingMode(participantContext),
1377
1469
  canUseCodexAppTools: participantContext.isOwner || serviceAgentMode,
1470
+ canUseCodexCanonTools: participantContext.isOwner && !serviceAgentMode,
1378
1471
  turnVerbosity: resolveTurnVerbosity({
1379
1472
  configured: configuredTurnVerbosity,
1380
1473
  conversationType: participantContext.conversationType,
1381
1474
  }),
1382
1475
  requestingUserId: getCodexRequestingUserId(message),
1476
+ ...(participantContext.isOwner && replyContext
1477
+ ? { replyContactTarget: ownerBoundReplyContactTarget(replyContext) }
1478
+ : {}),
1383
1479
  };
1384
1480
  }
1385
1481
  function runtimeCardRequestPayload(method, params) {
@@ -1421,6 +1517,23 @@ export async function main() {
1421
1517
  if (disposition === 'denied-non-owner') {
1422
1518
  return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
1423
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
+ }
1424
1537
  if (isCanonRuntimeControlToolCall(params)) {
1425
1538
  const command = parseCanonRuntimeControlRequest(params);
1426
1539
  if (!command) {
@@ -1715,18 +1828,23 @@ export async function main() {
1715
1828
  knownConversationIds.add(input.conversationId);
1716
1829
  if (input.turnDispatch && input.turnDispatch.kind !== 'run_turn') {
1717
1830
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed server-dispatched turn: ${input.turnDispatch.reason}`);
1718
- persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1831
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1719
1832
  return;
1720
1833
  }
1721
- if (isRecord(input.message.metadata)
1722
- && input.message.metadata.type === 'plan_approval_reply'
1723
- && typeof input.message.metadata.decision === 'string') {
1834
+ if (input.message.metadata?.type === 'plan_approval_reply') {
1835
+ if (!isCodexPlanApprovalReply(input.message.metadata, serviceAgentMode)) {
1836
+ // A service agent never advertises or enters plan mode. Consume stale
1837
+ // replies left by an older coding descriptor instead of turning them
1838
+ // into hidden plan/implementation prompts after an upgrade or restart.
1839
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1840
+ return;
1841
+ }
1724
1842
  const planId = readString(input.message.metadata, 'planId');
1725
1843
  if (planId && runtimeRequests.handleMessage(input.conversationId, {
1726
1844
  senderId: input.message.senderId,
1727
1845
  metadata: input.message.metadata,
1728
1846
  })) {
1729
- persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1847
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1730
1848
  return;
1731
1849
  }
1732
1850
  const session = await getOrCreateSession(input.conversationId);
@@ -1755,14 +1873,16 @@ export async function main() {
1755
1873
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
1756
1874
  }
1757
1875
  }
1758
- const renderedContent = renderInboundContent(input.message, materialized);
1876
+ const renderOptions = resolveInboundContentRenderOptions(input.isOwner);
1877
+ const renderedContent = renderInboundContent(input.message, materialized, renderOptions);
1759
1878
  const turnMetadata = normalizeTurnMetadata(input.message.metadata);
1760
1879
  const requestedPlanMode = turnMetadata?.requestedTurnMode === 'plan';
1761
- const planCommand = useAppServer
1762
- ? requestedPlanMode
1763
- ? { planMode: true, content: renderedContent }
1764
- : parsePlanCommand(renderedContent)
1765
- : { planMode: false, content: renderedContent };
1880
+ const planCommand = resolveCodexPlanCommand({
1881
+ content: renderedContent,
1882
+ requestedPlanMode,
1883
+ useAppServer,
1884
+ serviceAgentMode,
1885
+ });
1766
1886
  const content = planCommand.content;
1767
1887
  const hydrated = await loadHydratedInboundContext({
1768
1888
  conversationId: input.conversationId,
@@ -1773,6 +1893,7 @@ export async function main() {
1773
1893
  selfContexts: input.selfContexts,
1774
1894
  provenance: input.provenance,
1775
1895
  hydratedPage: input.hydratedPage,
1896
+ renderOptions,
1776
1897
  });
1777
1898
  const behavior = input.behavior ?? hydrated.behavior;
1778
1899
  const activeSelfContextId = hydrated.activeSelfContextId;
@@ -1799,7 +1920,7 @@ export async function main() {
1799
1920
  : decideAutoReply(participantContext, behavior);
1800
1921
  if (!autoReply.allow) {
1801
1922
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
1802
- persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1923
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1803
1924
  return;
1804
1925
  }
1805
1926
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
@@ -1823,11 +1944,11 @@ export async function main() {
1823
1944
  replyBehavior: 'suppress_auto_reply',
1824
1945
  },
1825
1946
  }).catch(() => { });
1826
- persistInboundRecoveryCursor(input.conversationId, input.message.id);
1947
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1827
1948
  return;
1828
1949
  }
1829
1950
  session.activeSelfContextId = activeSelfContextId;
1830
- const prompt = buildCanonPrompt({
1951
+ const basePrompt = buildCanonPrompt({
1831
1952
  content,
1832
1953
  conversationId: input.conversationId,
1833
1954
  participantContext,
@@ -1839,8 +1960,12 @@ export async function main() {
1839
1960
  message: input.message,
1840
1961
  ...(useAppServer ? { noReplyToolName: CODEX_NO_REPLY_MODEL_TOOL_NAME } : {}),
1841
1962
  });
1963
+ const lifecycleContext = input.isOwner
1964
+ ? formatPendingContactLifecycleContext(takeLocalRuntimeContactLifecycleEvents(runtimeId, input.conversationId))
1965
+ : null;
1966
+ const prompt = lifecycleContext ? `${basePrompt}\n\n${lifecycleContext}` : basePrompt;
1842
1967
  if (session.running && deliveryIntent === 'interrupt') {
1843
- 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));
1844
1969
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
1845
1970
  session.currentTurnAbortController?.abort(new Error('Codex turn interrupted by a newer message'));
1846
1971
  await session.adapter.interrupt().catch(() => { });
@@ -1848,7 +1973,7 @@ export async function main() {
1848
1973
  typingSignals.clear(input.conversationId).catch(() => { });
1849
1974
  return;
1850
1975
  }
1851
- 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));
1852
1977
  }
1853
1978
  function sendTurnArtifactFile(session, file) {
1854
1979
  return sendMediaFileMessage(client, session.conversationId, file.path, '', {
@@ -1879,6 +2004,8 @@ export async function main() {
1879
2004
  session.currentTurnOpenedAt = Date.now();
1880
2005
  session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
1881
2006
  session.currentTurnCanUseCodexAppTools = nextTurn.canUseCodexAppTools === true;
2007
+ session.currentTurnCanUseCodexCanonTools = nextTurn.canUseCodexCanonTools === true;
2008
+ session.currentTurnReplyContactTarget = nextTurn.replyContactTarget ?? null;
1882
2009
  session.currentTurnAbortController = new AbortController();
1883
2010
  // A continuation prompt (a plan-review result) carries none, and keeps the
1884
2011
  // conversation's last answer rather than silently reverting to verbose.
@@ -2283,11 +2410,7 @@ export async function main() {
2283
2410
  finally {
2284
2411
  session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
2285
2412
  session.currentTurnAbortController = null;
2286
- persistInboundRecoveryCursor(session.conversationId, nextTurn.sourceMessageId);
2287
- if (session.pendingDroppedRecoveryCursor) {
2288
- persistInboundRecoveryCursor(session.conversationId, session.pendingDroppedRecoveryCursor);
2289
- session.pendingDroppedRecoveryCursor = null;
2290
- }
2413
+ recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
2291
2414
  stopVisibleWorkSignal(session);
2292
2415
  session.running = false;
2293
2416
  session.state.state = 'idle';
@@ -2296,6 +2419,8 @@ export async function main() {
2296
2419
  session.currentTurnOpenedAt = null;
2297
2420
  session.currentTurnUpdatedAt = null;
2298
2421
  session.currentTurnCanUseCodexAppTools = false;
2422
+ session.currentTurnCanUseCodexCanonTools = false;
2423
+ session.currentTurnReplyContactTarget = null;
2299
2424
  session.currentTurnSilenced = false;
2300
2425
  session.lastAcceptedIntent = null;
2301
2426
  session.resetRequested = false;
@@ -2354,14 +2479,18 @@ export async function main() {
2354
2479
  });
2355
2480
  let codexSkills = [];
2356
2481
  const buildCurrentRuntimeDescriptor = () => ({
2357
- defaultWorkspaceId: workspaceOptions[0]?.id,
2482
+ ...(serviceAgentMode
2483
+ ? {}
2484
+ : {
2485
+ defaultWorkspaceId: workspaceOptions[0]?.id,
2486
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
2487
+ availableExecutionModes: hostAvailableExecutionModes,
2488
+ availablePermissionModes: [...codexPermissionEnvelope.availablePermissionModes],
2489
+ ...(codexPermissionEnvelope.defaultPermissionMode
2490
+ ? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
2491
+ : {}),
2492
+ }),
2358
2493
  ...(codexDefaultModel ? { defaultModel: codexDefaultModel } : {}),
2359
- availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
2360
- availableExecutionModes: hostAvailableExecutionModes,
2361
- availablePermissionModes: [...codexPermissionEnvelope.availablePermissionModes],
2362
- ...(codexPermissionEnvelope.defaultPermissionMode
2363
- ? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
2364
- : {}),
2365
2494
  runtimeDescriptor: buildCodexRuntimeDescriptor({
2366
2495
  models: codexModelOptions,
2367
2496
  effortOptions: codexEffortOptions,
@@ -2375,10 +2504,25 @@ export async function main() {
2375
2504
  supportsPlanMode: useAppServer,
2376
2505
  supportsCompact: useAppServer,
2377
2506
  supportsRichCards: useAppServer,
2507
+ supportsCanonCommunicationTools: useAppServer,
2378
2508
  skills: codexSkills,
2509
+ serviceAgentMode,
2379
2510
  }),
2380
2511
  });
2381
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
+ };
2382
2526
  async function refreshCodexSkillInventory(forceReload = false) {
2383
2527
  if (!useAppServer)
2384
2528
  return;
@@ -2421,6 +2565,11 @@ export async function main() {
2421
2565
  const session = sessions.get(conversationId);
2422
2566
  if (!session || session.closed)
2423
2567
  return;
2568
+ if (serviceAgentMode) {
2569
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring user session controls for service-agent runtime`);
2570
+ writeState(session);
2571
+ return;
2572
+ }
2424
2573
  let modelChanged = false;
2425
2574
  if (control.model && control.model !== session.state.model) {
2426
2575
  if (codexModelOptions.length > 0 && !codexModelOptions.some((option) => option.value === control.model)) {
@@ -2490,7 +2639,7 @@ export async function main() {
2490
2639
  if (type === 'stop_and_drop') {
2491
2640
  const droppedPrompts = session.queue.splice(0);
2492
2641
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
2493
- rememberDroppedRecoveryCursor(session, droppedPrompts);
2642
+ settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
2494
2643
  }
2495
2644
  if (session.running) {
2496
2645
  session.currentTurnAbortController?.abort(new Error(`Codex turn interrupted by ${type}`));
@@ -2577,10 +2726,27 @@ export async function main() {
2577
2726
  workspaceOptions,
2578
2727
  defaultCwd: workingDir,
2579
2728
  extraSessionConfigFields: CODEX_SESSION_CONFIG_FIELDS,
2580
- liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
2729
+ liveSessionConfigByConversation: new Map(Array.from(serviceAgentMode ? knownConversationIds : sessions.keys()).map((conversationId) => {
2730
+ const session = sessions.get(conversationId);
2731
+ if (serviceAgentMode) {
2732
+ return [
2733
+ conversationId,
2734
+ buildCodexServiceSnapshotConfig({
2735
+ model: codexDefaultModel ?? session?.state.model ?? undefined,
2736
+ permissionMode: codexPermissionEnvelope.defaultPermissionMode,
2737
+ effort: codexDefaultEffort ?? session?.state.effort ?? undefined,
2738
+ workspaceId: resolveWorkspaceIdForBaseCwd(workingDir) ?? undefined,
2739
+ }),
2740
+ ];
2741
+ }
2742
+ if (!session) {
2743
+ // Non-service snapshots are published only for live sessions,
2744
+ // but keep this branch total if that invariant changes.
2745
+ return [conversationId, {}];
2746
+ }
2581
2747
  const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
2582
2748
  return [
2583
- session.conversationId,
2749
+ conversationId,
2584
2750
  buildCodexLiveSessionConfig({
2585
2751
  model: session.state.model,
2586
2752
  permissionMode: session.state.permissionMode,
@@ -2668,6 +2834,90 @@ export async function main() {
2668
2834
  publishRuntimeDetailsInFlight = false;
2669
2835
  }
2670
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
+ };
2671
2921
  const stream = new CanonStream({
2672
2922
  apiKey,
2673
2923
  agentId,
@@ -2679,9 +2929,10 @@ export async function main() {
2679
2929
  return;
2680
2930
  if (!claimInboundMessageId(message.id))
2681
2931
  return;
2932
+ recoveryCheckpointsFor(payload.conversationId).track(message.id);
2682
2933
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2683
2934
  console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2684
- persistInboundRecoveryCursorWhenIdle(payload.conversationId, message.id);
2935
+ recoveryCheckpointsFor(payload.conversationId).settle(message.id);
2685
2936
  settleInboundMessageId(message.id, true);
2686
2937
  return;
2687
2938
  }
@@ -2706,9 +2957,13 @@ export async function main() {
2706
2957
  onConversationUpdated: (payload) => {
2707
2958
  handleConversationUpdated(payload);
2708
2959
  },
2960
+ onContactRequestUpdated: (payload) => {
2961
+ recordContactLifecycleEvent(payload);
2962
+ },
2709
2963
  onConnected: () => {
2710
2964
  streamConnected = true;
2711
2965
  void publishRuntimeHeartbeat();
2966
+ observeReconnectRecovery('reconnect', reconnectRecovery.onConnected());
2712
2967
  console.error('[canon-codex] SSE connected');
2713
2968
  },
2714
2969
  onDisconnected: () => {
@@ -2716,6 +2971,7 @@ export async function main() {
2716
2971
  runtimeState.clearAgentRuntime().catch(() => { });
2717
2972
  console.error('[canon-codex] SSE disconnected');
2718
2973
  },
2974
+ onReplayExpired: () => observeReconnectRecovery('replay-expired', reconnectRecovery.onReplayExpired()),
2719
2975
  onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
2720
2976
  },
2721
2977
  });
@@ -2734,57 +2990,10 @@ export async function main() {
2734
2990
  catch (error) {
2735
2991
  console.error('[canon-codex] Failed to load startup conversations:', error);
2736
2992
  }
2737
- for (const conversationId of knownConversationIds) {
2738
- try {
2739
- const cursor = loadRuntimeSessionState(runtimeId, {
2740
- conversationId,
2741
- baseCwd: workingDir,
2742
- })?.lastInboundMessageId ?? null;
2743
- const recovered = await collectMissedInboundMessages({
2744
- fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
2745
- cursor,
2746
- agentId,
2747
- });
2748
- if (recovered.mode === 'truncated-window') {
2749
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovery cursor was not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; replaying the bounded recent window`);
2750
- }
2751
- for (const message of recovered.messages) {
2752
- const isPlanReply = message.metadata?.type === 'plan_approval_reply';
2753
- if (!isPlanReply && !shouldTriggerAgentTurn({
2754
- senderType: message.senderType,
2755
- metadata: message.metadata,
2756
- }).allow) {
2757
- persistInboundRecoveryCursorWhenIdle(conversationId, message.id);
2758
- continue;
2759
- }
2760
- if (!claimInboundMessageId(message.id))
2761
- continue;
2762
- try {
2763
- await enqueueInboundMessage({
2764
- conversationId,
2765
- message,
2766
- senderName: message.senderName || message.senderId,
2767
- isOwner: message.senderId === ownerId,
2768
- behavior: recovered.newestPage.behavior,
2769
- activeSelfContextId: recovered.newestPage.activeSelfContextIdByMessageId?.[message.id] ?? null,
2770
- selfContexts: recovered.newestPage.selfContexts,
2771
- hydratedPage: recovered.newestPage,
2772
- });
2773
- settleInboundMessageId(message.id, true);
2774
- }
2775
- catch (error) {
2776
- settleInboundMessageId(message.id, false);
2777
- throw error;
2778
- }
2779
- }
2780
- if (recovered.messages.length > 0) {
2781
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
2782
- }
2783
- }
2784
- catch (error) {
2785
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
2786
- }
2787
- }
2993
+ await reconnectRecovery.recoverNow().catch((error) => {
2994
+ console.error('[canon-codex] Startup recovery failed:', error instanceof Error ? error.message : error);
2995
+ });
2996
+ startupRecoveryComplete = true;
2788
2997
  startCodexStreamInBackground(stream, (error) => {
2789
2998
  console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
2790
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',
@@ -3,6 +3,8 @@ export declare function buildCodexThreadPolicyFingerprint(input: {
3
3
  baseCwd: string;
4
4
  executionMode?: ExecutionEnvironmentMode;
5
5
  permissionMode?: string | null;
6
+ model?: string | null;
7
+ serviceAgentMode?: boolean;
6
8
  sandbox?: string | null;
7
9
  approvalPolicy?: string | null;
8
10
  fullAuto?: boolean;
@@ -6,6 +6,14 @@ export function buildCodexThreadPolicyFingerprint(input) {
6
6
  baseCwd: input.baseCwd,
7
7
  executionMode: input.executionMode ?? null,
8
8
  permissionMode: input.permissionMode ?? null,
9
+ // Service agents make the model a host-owned runtime choice. Include it,
10
+ // plus an explicit service-mode marker, so the first service-agent
11
+ // release drops coding threads that may retain a member-selected model
12
+ // and later host model changes start a clean thread. Ordinary Codex host
13
+ // fingerprints intentionally remain byte-for-byte compatible.
14
+ ...(input.serviceAgentMode
15
+ ? { serviceAgentMode: true, model: input.model ?? null }
16
+ : {}),
9
17
  sandbox: input.sandbox ?? null,
10
18
  approvalPolicy: input.approvalPolicy ?? null,
11
19
  fullAuto: input.fullAuto === true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.26.1",
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": {