@canonmsg/codex-plugin 0.25.1 → 0.26.1

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
@@ -60,6 +60,33 @@ You do not need a git repo for host mode. Any readable working directory is vali
60
60
  - Quiet group turns: in groups the host shows the thinking indicator and the
61
61
  answer only; direct chats keep the live preview and margin activity
62
62
 
63
+ ### Service-agent mode
64
+
65
+ Long-running customer agents can use the same Canon host without exposing its
66
+ coding controls:
67
+
68
+ ```bash
69
+ canon-codex \
70
+ --cwd /srv/agent-workspace \
71
+ --service-agent \
72
+ --no-native-vision
73
+ ```
74
+
75
+ `--service-agent` removes the coding-oriented dynamic tools and exposes only
76
+ `codex_app.canon_runtime_control` plus `codex_app.no_reply`. Runtime control is
77
+ bound by the host to the active Canon conversation and authenticated human; the
78
+ model cannot select another user, conversation, or responder. It can display a
79
+ card with `send_card`, wait for an action card with `request_card`, or ask one
80
+ standalone question with `request_input`.
81
+
82
+ Authorized conversation members may use a service agent even when they do not
83
+ own its Canon identity. Normal coding-host sessions keep their existing
84
+ owner-only execution boundary.
85
+
86
+ `--no-native-vision` keeps inbound attachment paths in the prompt while
87
+ suppressing Codex's native image input. This lets a service agent use its
88
+ purpose-built OCR tool as the only image-recognition path.
89
+
63
90
  ## Transports
64
91
 
65
92
  The host picks a transport at startup and logs the choice as
@@ -382,11 +382,26 @@ export class CodexAppServerAdapter {
382
382
  }
383
383
  async handleServerRequest(request) {
384
384
  try {
385
+ const params = isRecord(request.params) ? request.params : {};
386
+ // Detached child threads may use the ordinary coding-thread bridge, but
387
+ // Canon conversation controls belong only to the foreground human turn.
388
+ // A fork can inherit dynamic-tool declarations, so enforce this again at
389
+ // dispatch instead of relying only on the child thread's advertised list.
390
+ if (isForegroundCanonToolCall(params) && !this.isCurrentThreadNotification(params)) {
391
+ this.write({
392
+ id: request.id,
393
+ error: {
394
+ code: -32001,
395
+ message: 'Server request does not belong to the active Canon turn',
396
+ },
397
+ });
398
+ return;
399
+ }
385
400
  const result = this.currentRequestHandler
386
401
  ? await this.currentRequestHandler({
387
402
  id: request.id,
388
403
  method: request.method,
389
- params: isRecord(request.params) ? request.params : {},
404
+ params,
390
405
  })
391
406
  : defaultServerRequestResult(request.method);
392
407
  this.write({ id: request.id, result });
@@ -616,6 +631,15 @@ export class CodexAppServerAdapter {
616
631
  this.child.stdin.write(`${JSON.stringify(message)}\n`);
617
632
  }
618
633
  }
634
+ function isForegroundCanonToolCall(params) {
635
+ const rawTool = readString(params, 'tool');
636
+ if (!rawTool)
637
+ return false;
638
+ const tool = rawTool.startsWith('codex_app.')
639
+ ? rawTool.slice('codex_app.'.length)
640
+ : rawTool;
641
+ return tool === 'canon_runtime_control' || tool === 'no_reply';
642
+ }
619
643
  function parseJson(line) {
620
644
  try {
621
645
  const parsed = JSON.parse(line);
@@ -43,6 +43,7 @@ type DynamicToolCallResponse = {
43
43
  * the host's turn, not to the app-server bridge.
44
44
  */
45
45
  export declare const CODEX_NO_REPLY_TOOL_NAME = "no_reply";
46
+ export declare const CANON_RUNTIME_CONTROL_TOOL_NAME = "canon_runtime_control";
46
47
  /**
47
48
  * What the model actually sees for the tool above, for prompt text that names
48
49
  * it (the group posture cue). Only meaningful on the app-server transport —
@@ -50,10 +51,28 @@ export declare const CODEX_NO_REPLY_TOOL_NAME = "no_reply";
50
51
  */
51
52
  export declare const CODEX_NO_REPLY_MODEL_TOOL_NAME = "codex_app.no_reply";
52
53
  export declare const CODEX_APP_DYNAMIC_TOOLS: ReadonlyArray<DynamicToolSpec>;
54
+ /** The complete model-visible surface for non-coding service agents. */
55
+ export declare const CODEX_SERVICE_AGENT_DYNAMIC_TOOLS: ReadonlyArray<DynamicToolSpec>;
56
+ /**
57
+ * Tools exposed to detached Codex threads created through the bridge. Canon
58
+ * conversation controls belong only to the foreground turn that received the
59
+ * human message; a child thread has neither that turn nor its responder.
60
+ */
61
+ export declare const CODEX_DETACHED_THREAD_DYNAMIC_TOOLS: ReadonlyArray<DynamicToolSpec>;
53
62
  export declare function isCodexAppToolCall(params: Record<string, unknown>): boolean;
54
63
  export declare function deniedCodexAppToolResult(reason: string): DynamicToolCallResponse;
64
+ export declare function successfulCodexAppToolResult(payload: unknown): DynamicToolCallResponse;
55
65
  /** True when this dynamic-tool call is the deliberate-silence verb. */
56
66
  export declare function isCodexNoReplyToolCall(params: CodexAppToolCallParams): boolean;
67
+ export declare function isCanonRuntimeControlToolCall(params: CodexAppToolCallParams): boolean;
68
+ export declare function isCodexServiceAgentToolCall(params: CodexAppToolCallParams): boolean;
69
+ export interface CanonRuntimeControlRequest {
70
+ action: 'send_card' | 'request_card' | 'request_input';
71
+ card?: unknown;
72
+ prompt?: string;
73
+ placeholder?: string;
74
+ }
75
+ export declare function parseCanonRuntimeControlRequest(params: CodexAppToolCallParams): CanonRuntimeControlRequest | null;
57
76
  /** Private rationale, when the model supplied one. Logged, never rendered. */
58
77
  export declare function readCodexNoReplyReason(params: CodexAppToolCallParams): string | undefined;
59
78
  /**
@@ -89,12 +89,51 @@ function tool(name, description, inputSchema, deferLoading = true) {
89
89
  * the host's turn, not to the app-server bridge.
90
90
  */
91
91
  export const CODEX_NO_REPLY_TOOL_NAME = 'no_reply';
92
+ export const CANON_RUNTIME_CONTROL_TOOL_NAME = 'canon_runtime_control';
92
93
  /**
93
94
  * What the model actually sees for the tool above, for prompt text that names
94
95
  * it (the group posture cue). Only meaningful on the app-server transport —
95
96
  * the `exec --json` transport registers no dynamic tools.
96
97
  */
97
98
  export const CODEX_NO_REPLY_MODEL_TOOL_NAME = `codex_app.${CODEX_NO_REPLY_TOOL_NAME}`;
99
+ const CANON_RUNTIME_CONTROL_TOOL = tool(CANON_RUNTIME_CONTROL_TOOL_NAME, 'Interact with the current Canon conversation. Send a display card, request one card response, '
100
+ + 'or ask one short question. Canon binds the current conversation and authenticated responder; '
101
+ + 'never ask for or invent routing ids.', {
102
+ type: 'object',
103
+ additionalProperties: false,
104
+ properties: {
105
+ action: {
106
+ type: 'string',
107
+ enum: ['send_card', 'request_card', 'request_input'],
108
+ },
109
+ card: {
110
+ type: 'object',
111
+ description: 'A canon.card.v1 document. Use request_card when it contains actions.',
112
+ },
113
+ prompt: {
114
+ type: 'string',
115
+ description: 'A short question for request_input.',
116
+ },
117
+ placeholder: {
118
+ type: 'string',
119
+ description: 'Optional example or formatting hint for request_input.',
120
+ },
121
+ },
122
+ required: ['action'],
123
+ }, false);
124
+ 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
+ + 'chats when you have nothing to add — no message is created, so no other '
126
+ + 'member or agent is triggered. Optional private reason (logged, never '
127
+ + 'shown). After calling this, produce no further text.', {
128
+ type: 'object',
129
+ additionalProperties: false,
130
+ properties: {
131
+ reason: {
132
+ type: 'string',
133
+ description: 'Never rendered; logged only.',
134
+ },
135
+ },
136
+ }, false);
98
137
  export const CODEX_APP_DYNAMIC_TOOLS = [
99
138
  tool('automation_update', 'Create, update, view, or delete Codex app automations. Canon exposes the name for compatibility, but does not manage Desktop automations.', {
100
139
  type: 'object',
@@ -205,24 +244,23 @@ export const CODEX_APP_DYNAMIC_TOOLS = [
205
244
  },
206
245
  required: ['threadId', 'title'],
207
246
  }),
247
+ CANON_RUNTIME_CONTROL_TOOL,
208
248
  // Canon's `no_reply` verb, projected into the only model-visible tool surface
209
- // the Codex transport gives us. It is answered by the Canon host itself (see
210
- // `handleCodexServerRequest`), never by `handleCodexAppToolCall` — the host
211
- // owns the turn this call is about.
212
- tool(CODEX_NO_REPLY_TOOL_NAME, 'End your turn without posting anything to the conversation. Use it in group '
213
- + 'chats when you have nothing to add — no message is created, so no other '
214
- + 'member or agent is triggered. Optional private reason (logged, never '
215
- + 'shown). After calling this, produce no further text.', {
216
- type: 'object',
217
- additionalProperties: false,
218
- properties: {
219
- reason: {
220
- type: 'string',
221
- description: 'Never rendered; logged only.',
222
- },
223
- },
224
- }, false),
249
+ // the Codex transport gives us. It is answered by the Canon host itself.
250
+ CODEX_NO_REPLY_TOOL,
251
+ ];
252
+ /** The complete model-visible surface for non-coding service agents. */
253
+ export const CODEX_SERVICE_AGENT_DYNAMIC_TOOLS = [
254
+ CANON_RUNTIME_CONTROL_TOOL,
255
+ CODEX_NO_REPLY_TOOL,
225
256
  ];
257
+ /**
258
+ * Tools exposed to detached Codex threads created through the bridge. Canon
259
+ * conversation controls belong only to the foreground turn that received the
260
+ * human message; a child thread has neither that turn nor its responder.
261
+ */
262
+ 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));
226
264
  const CODEX_APP_TOOL_NAMES = new Set(CODEX_APP_DYNAMIC_TOOLS.map((entry) => String(entry.name)));
227
265
  const UNSUPPORTED_TOOLS = new Map([
228
266
  ['automation_update', 'Canon does not manage Codex Desktop automations.'],
@@ -246,10 +284,38 @@ export function isCodexAppToolCall(params) {
246
284
  export function deniedCodexAppToolResult(reason) {
247
285
  return toolResult(false, { error: reason });
248
286
  }
287
+ export function successfulCodexAppToolResult(payload) {
288
+ return toolResult(true, payload);
289
+ }
249
290
  /** True when this dynamic-tool call is the deliberate-silence verb. */
250
291
  export function isCodexNoReplyToolCall(params) {
251
292
  return normalizeToolName(params.tool) === CODEX_NO_REPLY_TOOL_NAME;
252
293
  }
294
+ export function isCanonRuntimeControlToolCall(params) {
295
+ return normalizeToolName(params.tool) === CANON_RUNTIME_CONTROL_TOOL_NAME;
296
+ }
297
+ export function isCodexServiceAgentToolCall(params) {
298
+ const toolName = normalizeToolName(params.tool);
299
+ return toolName === CANON_RUNTIME_CONTROL_TOOL_NAME || toolName === CODEX_NO_REPLY_TOOL_NAME;
300
+ }
301
+ export function parseCanonRuntimeControlRequest(params) {
302
+ if (!isCanonRuntimeControlToolCall(params))
303
+ return null;
304
+ const args = parseToolArguments(params.arguments);
305
+ const allowedKeys = new Set(['action', 'card', 'prompt', 'placeholder']);
306
+ if (Object.keys(args).some((key) => !allowedKeys.has(key)))
307
+ return null;
308
+ const action = readString(args, 'action');
309
+ if (action !== 'send_card' && action !== 'request_card' && action !== 'request_input') {
310
+ return null;
311
+ }
312
+ return {
313
+ action,
314
+ ...(args.card !== undefined ? { card: args.card } : {}),
315
+ ...(readString(args, 'prompt') ? { prompt: readString(args, 'prompt') } : {}),
316
+ ...(readString(args, 'placeholder') ? { placeholder: readString(args, 'placeholder') } : {}),
317
+ };
318
+ }
253
319
  /** Private rationale, when the model supplied one. Logged, never rendered. */
254
320
  export function readCodexNoReplyReason(params) {
255
321
  const args = parseToolArguments(params.arguments);
@@ -314,6 +380,12 @@ export async function handleCodexAppToolCall(runtime, params) {
314
380
  error: 'no_reply is answered by the Canon host, not the app-tool bridge.',
315
381
  });
316
382
  }
383
+ if (toolName === CANON_RUNTIME_CONTROL_TOOL_NAME) {
384
+ return toolResult(false, {
385
+ tool: toolName,
386
+ error: 'canon_runtime_control is answered by the Canon host, not the app-tool bridge.',
387
+ });
388
+ }
317
389
  const unsupportedReason = UNSUPPORTED_TOOLS.get(toolName);
318
390
  if (unsupportedReason) {
319
391
  return toolResult(false, { tool: toolName, error: unsupportedReason });
@@ -366,7 +438,7 @@ async function createThread(runtime, args) {
366
438
  const started = await runtime.adapter.requestAppServer('thread/start', {
367
439
  cwd,
368
440
  ...(readString(args, 'model') ?? runtime.model ? { model: readString(args, 'model') ?? runtime.model } : {}),
369
- dynamicTools: CODEX_APP_DYNAMIC_TOOLS,
441
+ dynamicTools: CODEX_DETACHED_THREAD_DYNAMIC_TOOLS,
370
442
  experimentalRawEvents: false,
371
443
  persistExtendedHistory: true,
372
444
  });
package/dist/host.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { type TurnArtifactRoutingDecision, type TurnArtifactRoutingMode } from '@canonmsg/coding-agent-host';
3
- import { type CanonRuntimeCommandDescriptor, type CanonRuntimeDescriptor, type CanonRuntimePresentationPolicy, type ExecutionEnvironmentMode, type WorkspaceOption, type CanonWorkspaceRootMetadata, type RuntimeStreamingPayload, type TurnLifecycleState, type TurnOutputBlock, type TurnVerbosity, type TurnVerbosityConfig } from '@canonmsg/core';
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
+ import { type CodexSandboxMode } from './adapter.js';
4
5
  import { type CodexSkillMetadata } from './app-server-adapter.js';
6
+ import { deriveCodexPermissionEnvelope } from './permission-mode.js';
5
7
  import { type CodexControlOption } from './model-catalog.js';
6
8
  interface HostSessionState {
7
9
  lastError?: string;
@@ -68,6 +70,31 @@ export declare function getCodexRequestingUserId(message: {
68
70
  senderId: string;
69
71
  senderType?: 'human' | 'ai_agent';
70
72
  }): string | null;
73
+ export declare function resolveSessionExecutionMode(config: {
74
+ executionMode?: ExecutionEnvironmentMode;
75
+ } | null | undefined, serviceAgentMode?: boolean): ExecutionEnvironmentMode;
76
+ export declare function resolveWorkspaceCwd(config: {
77
+ workspaceId?: string;
78
+ retiredWorkspaceConfig?: boolean;
79
+ } | null, serviceAgentMode?: boolean): string;
80
+ interface CodexEffectiveRuntimePolicy {
81
+ model?: string;
82
+ permissionMode?: string;
83
+ sandbox: CodexSandboxMode | null;
84
+ fullAuto: boolean;
85
+ bypassApprovalsAndSandbox: boolean;
86
+ fingerprint: string;
87
+ }
88
+ export declare function resolveCodexEffectiveRuntimePolicy(input: {
89
+ args: Record<string, unknown>;
90
+ config: {
91
+ model?: string;
92
+ permissionMode?: string;
93
+ } | null | undefined;
94
+ permissionEnvelope: ReturnType<typeof deriveCodexPermissionEnvelope>;
95
+ environment: Pick<PreparedExecutionEnvironment, 'baseCwd' | 'mode'>;
96
+ serviceAgentMode?: boolean;
97
+ }): CodexEffectiveRuntimePolicy;
71
98
  /**
72
99
  * `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
73
100
  * per-conversation-type default".
@@ -137,6 +164,7 @@ export declare function planCodexStreamingWrite(input: {
137
164
  * same decision at its `text_delta` handler.
138
165
  */
139
166
  export declare function shouldStopTypingDotsOnStreamedText(turnVerbosity: TurnVerbosity): boolean;
167
+ export declare function selectCodexNativeImagePaths(imagePaths: readonly string[], nativeVisionEnabled: boolean): string[];
140
168
  /**
141
169
  * Whether this turn may post the media it generated in the workspace.
142
170
  *
package/dist/host.js CHANGED
@@ -6,10 +6,11 @@ import { dirname } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
8
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, parseRuntimeCardV1, 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';
9
+ import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
10
+ import { validateCard } from '@canonmsg/rich-cards';
10
11
  import { CodexConversationAdapter, } from './adapter.js';
11
12
  import { CodexAppServerAdapter, } from './app-server-adapter.js';
12
- import { CODEX_APP_DYNAMIC_TOOLS, CODEX_NO_REPLY_MODEL_TOOL_NAME, answerCodexNoReply, classifyCodexAppToolRequest, deniedCodexAppToolResult, handleCodexAppToolCall, isCodexAppToolCall, readCodexNoReplyReason, } from './codex-app-tools.js';
13
+ import { CODEX_APP_DYNAMIC_TOOLS, CODEX_NO_REPLY_MODEL_TOOL_NAME, CODEX_SERVICE_AGENT_DYNAMIC_TOOLS, answerCodexNoReply, classifyCodexAppToolRequest, deniedCodexAppToolResult, handleCodexAppToolCall, isCanonRuntimeControlToolCall, isCodexAppToolCall, isCodexServiceAgentToolCall, parseCanonRuntimeControlRequest, readCodexNoReplyReason, successfulCodexAppToolResult, } from './codex-app-tools.js';
13
14
  import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
14
15
  import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
15
16
  import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
@@ -42,6 +43,8 @@ COMMON FLAGS
42
43
  How much of a turn's middle readers see.
43
44
  Default (auto): verbose in direct chats,
44
45
  quiet in groups. Env: CANON_TURN_VERBOSITY
46
+ --service-agent Expose Canon conversation tools, not coding tools
47
+ --no-native-vision Keep attachment paths but omit native image input
45
48
  --help, -h Show this help
46
49
  --version, -V Show package version
47
50
 
@@ -259,15 +262,17 @@ async function loadSessionConfig(conversationId, agentId, rtdb) {
259
262
  extraStringFields: CODEX_SESSION_CONFIG_FIELDS,
260
263
  });
261
264
  }
262
- function resolveSessionExecutionMode(config) {
265
+ export function resolveSessionExecutionMode(config, serviceAgentMode = false) {
266
+ if (serviceAgentMode)
267
+ return 'locked';
263
268
  if (config?.executionMode)
264
269
  return config.executionMode;
265
270
  throw new ExecutionEnvironmentError('Session config is missing an execution mode.', 'Choose Isolated worktree or Use shared project before starting this coding session.');
266
271
  }
267
- function resolveWorkspaceCwd(config) {
272
+ export function resolveWorkspaceCwd(config, serviceAgentMode = false) {
268
273
  return resolveHostWorkspaceCwd({
269
274
  workspaceOptions,
270
- config,
275
+ config: serviceAgentMode ? null : config,
271
276
  defaultCwd: workingDir,
272
277
  });
273
278
  }
@@ -286,9 +291,11 @@ function stringArg(args, key) {
286
291
  function boolArg(args, key) {
287
292
  return args[key] === true;
288
293
  }
289
- function resolveCodexEffectiveRuntimePolicy(input) {
294
+ export function resolveCodexEffectiveRuntimePolicy(input) {
290
295
  const model = input.config?.model ?? stringArg(input.args, 'model');
291
- const permissionMode = input.config?.permissionMode ?? input.permissionEnvelope.defaultPermissionMode;
296
+ const permissionMode = input.serviceAgentMode
297
+ ? input.permissionEnvelope.defaultPermissionMode
298
+ : input.config?.permissionMode ?? input.permissionEnvelope.defaultPermissionMode;
292
299
  if (permissionMode
293
300
  && !input.permissionEnvelope.availablePermissionModes.some((option) => option.value === permissionMode)) {
294
301
  throw new ExecutionEnvironmentError(`Permission mode "${permissionMode}" is not supported by this Codex host.`, 'This Canon host was started with stricter approval settings. Choose one of the advertised permission modes or restart the host with more permissive flags.');
@@ -538,6 +545,9 @@ export function planCodexStreamingWrite(input) {
538
545
  export function shouldStopTypingDotsOnStreamedText(turnVerbosity) {
539
546
  return turnVerbosity !== 'quiet';
540
547
  }
548
+ export function selectCodexNativeImagePaths(imagePaths, nativeVisionEnabled) {
549
+ return nativeVisionEnabled ? [...imagePaths] : [];
550
+ }
541
551
  /**
542
552
  * Whether this turn may post the media it generated in the workspace.
543
553
  *
@@ -585,6 +595,8 @@ export async function main() {
585
595
  'show-runtime-detail': { type: 'string', multiple: true },
586
596
  'hide-runtime-detail': { type: 'string', multiple: true },
587
597
  'turn-verbosity': { type: 'string' },
598
+ 'service-agent': { type: 'boolean' },
599
+ 'no-native-vision': { type: 'boolean' },
588
600
  'full-auto': { type: 'boolean' },
589
601
  'dangerously-bypass-approvals-and-sandbox': { type: 'boolean' },
590
602
  },
@@ -600,6 +612,11 @@ export async function main() {
600
612
  env: process.env.CANON_TURN_VERBOSITY,
601
613
  onWarning: (message) => console.error(`[canon-codex] ${message}`),
602
614
  });
615
+ const serviceAgentMode = args['service-agent'] === true;
616
+ const nativeVisionEnabled = args['no-native-vision'] !== true;
617
+ const codexDynamicTools = serviceAgentMode
618
+ ? CODEX_SERVICE_AGENT_DYNAMIC_TOOLS
619
+ : CODEX_APP_DYNAMIC_TOOLS;
603
620
  workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
604
621
  const workspaceDiscovery = buildConfiguredWorkspaceOptionsWithRoots({
605
622
  primaryCwd: workingDir,
@@ -1116,6 +1133,8 @@ export async function main() {
1116
1133
  if (!session)
1117
1134
  return;
1118
1135
  session.closed = true;
1136
+ session.currentTurnAbortController?.abort(new Error('Codex session closed'));
1137
+ session.currentTurnAbortController = null;
1119
1138
  stopVisibleWorkSignal(session);
1120
1139
  if ('close' in session.adapter && typeof session.adapter.close === 'function') {
1121
1140
  session.adapter.close();
@@ -1139,6 +1158,7 @@ export async function main() {
1139
1158
  session.activeSelfContextId = null;
1140
1159
  session.state.lastError = undefined;
1141
1160
  if (session.running) {
1161
+ session.currentTurnAbortController?.abort(new Error('Codex session reset'));
1142
1162
  await session.adapter.interrupt();
1143
1163
  session.turnState = 'interrupted';
1144
1164
  }
@@ -1186,8 +1206,8 @@ export async function main() {
1186
1206
  }
1187
1207
  const creation = (async () => {
1188
1208
  const config = await loadSessionConfig(conversationId, agentId, rtdb);
1189
- const sessionExecutionMode = resolveSessionExecutionMode(config);
1190
- const workspaceCwd = resolveWorkspaceCwd(config);
1209
+ const sessionExecutionMode = resolveSessionExecutionMode(config, serviceAgentMode);
1210
+ const workspaceCwd = resolveWorkspaceCwd(config, serviceAgentMode);
1191
1211
  const environment = prepareConversationEnvironment({
1192
1212
  agentId,
1193
1213
  conversationId,
@@ -1201,6 +1221,7 @@ export async function main() {
1201
1221
  config,
1202
1222
  permissionEnvelope: codexPermissionEnvelope,
1203
1223
  environment,
1224
+ serviceAgentMode,
1204
1225
  });
1205
1226
  const modelGuard = buildCodexModelGuardMessage(policy.model, codexCliStatus);
1206
1227
  if (modelGuard) {
@@ -1226,7 +1247,7 @@ export async function main() {
1226
1247
  configOverrides: args.config ?? [],
1227
1248
  fullAuto: policy.fullAuto,
1228
1249
  bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
1229
- dynamicTools: CODEX_APP_DYNAMIC_TOOLS,
1250
+ dynamicTools: codexDynamicTools,
1230
1251
  })
1231
1252
  : new CodexConversationAdapter({
1232
1253
  cwd: sessionCwd,
@@ -1259,6 +1280,7 @@ export async function main() {
1259
1280
  currentTurnOpenedAt: null,
1260
1281
  currentTurnUpdatedAt: null,
1261
1282
  currentTurnCanUseCodexAppTools: false,
1283
+ currentTurnAbortController: null,
1262
1284
  // Corrected by the first turn that runs; a session with no turn
1263
1285
  // publishes nothing anyway, and quiet is never an accident.
1264
1286
  turnVerbosity: 'verbose',
@@ -1333,7 +1355,10 @@ export async function main() {
1333
1355
  // No `turnVerbosity`: this prompt continues the same conversation, so it
1334
1356
  // keeps whatever the session last resolved rather than reverting to the
1335
1357
  // default.
1336
- enqueuePrompt(session, prompt, 'queue', false, result.receiptId ?? null, false, [], [], result.status !== 'approve', { requestingUserId: responseUserId });
1358
+ enqueuePrompt(session, prompt, 'queue', false, result.receiptId ?? null, false, [], [], result.status !== 'approve', {
1359
+ canUseCodexAppTools: serviceAgentMode || responseUserId === ownerId,
1360
+ requestingUserId: responseUserId,
1361
+ });
1337
1362
  }
1338
1363
  function resolveArtifactRoutingMode(participantContext) {
1339
1364
  return participantContext.conversationType === 'direct' && participantContext.isOwner
@@ -1349,7 +1374,7 @@ export async function main() {
1349
1374
  function resolveCodexTurnModes(participantContext, message) {
1350
1375
  return {
1351
1376
  artifactRoutingMode: resolveArtifactRoutingMode(participantContext),
1352
- canUseCodexAppTools: participantContext.isOwner,
1377
+ canUseCodexAppTools: participantContext.isOwner || serviceAgentMode,
1353
1378
  turnVerbosity: resolveTurnVerbosity({
1354
1379
  configured: configuredTurnVerbosity,
1355
1380
  conversationType: participantContext.conversationType,
@@ -1371,6 +1396,9 @@ export async function main() {
1371
1396
  const params = request.params;
1372
1397
  const expiresAt = Date.now() + 30 * 60_000;
1373
1398
  if (request.method === 'item/tool/call' && isCodexAppToolCall(params)) {
1399
+ if (serviceAgentMode && !isCodexServiceAgentToolCall(params)) {
1400
+ return deniedCodexAppToolResult('This service agent exposes only Canon conversation tools.');
1401
+ }
1374
1402
  // The admission order — no_reply above the owner gate — is pinned by
1375
1403
  // `classifyCodexAppToolRequest`'s tests, not by the shape of this block.
1376
1404
  const disposition = classifyCodexAppToolRequest({
@@ -1393,6 +1421,102 @@ export async function main() {
1393
1421
  if (disposition === 'denied-non-owner') {
1394
1422
  return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
1395
1423
  }
1424
+ if (isCanonRuntimeControlToolCall(params)) {
1425
+ const command = parseCanonRuntimeControlRequest(params);
1426
+ if (!command) {
1427
+ return deniedCodexAppToolResult('Invalid canon_runtime_control arguments.');
1428
+ }
1429
+ if (command.action === 'request_input') {
1430
+ if (!command.prompt) {
1431
+ return deniedCodexAppToolResult('request_input requires prompt.');
1432
+ }
1433
+ const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
1434
+ const inputId = `codex_input_${randomUUID()}`;
1435
+ const question = command.placeholder
1436
+ ? `${command.prompt}\n\nFormat hint: ${command.placeholder}`
1437
+ : command.prompt;
1438
+ const response = await runtimeRequests.request('input', session.conversationId, {
1439
+ kind: 'clarify',
1440
+ title: 'Input requested',
1441
+ prompt: command.prompt,
1442
+ questions: [{ id: 'response', header: 'Response', question, allowOther: true }],
1443
+ ...(responseRouting.responseUserId
1444
+ ? { responseUserId: responseRouting.responseUserId }
1445
+ : {}),
1446
+ turnId: session.currentTurnId ?? undefined,
1447
+ }, {
1448
+ requestId: inputId,
1449
+ expiresAt,
1450
+ signal: session.currentTurnAbortController?.signal,
1451
+ onCreated: () => {
1452
+ session.turnState = 'waiting_input';
1453
+ markTurnProgress(session);
1454
+ stopVisibleWorkSignal(session);
1455
+ writeTurn(session);
1456
+ writeCodexStreaming(session, null, 'waiting_input');
1457
+ },
1458
+ });
1459
+ resumeTurnFromWaiting(session);
1460
+ return successfulCodexAppToolResult(response);
1461
+ }
1462
+ const validation = validateCard(command.card);
1463
+ if (!validation.ok || !validation.card) {
1464
+ return deniedCodexAppToolResult(`Invalid canon.card.v1 card: ${validation.errors.join('; ')}`);
1465
+ }
1466
+ const card = validation.card;
1467
+ const interactive = card.blocks.some((block) => block.kind === 'actions');
1468
+ if (command.action === 'send_card') {
1469
+ if (interactive) {
1470
+ return deniedCodexAppToolResult('send_card cannot contain actions; use request_card.');
1471
+ }
1472
+ const cardId = card.cardId ?? `codex_card_${randomUUID()}`;
1473
+ const created = await client.createRuntimeCardRequest({
1474
+ conversationId: session.conversationId,
1475
+ card: { ...card, cardId },
1476
+ cardId,
1477
+ expiresAt,
1478
+ turnId: session.currentTurnId ?? undefined,
1479
+ });
1480
+ return successfulCodexAppToolResult({
1481
+ status: 'sent',
1482
+ cardId: created.cardId,
1483
+ messageId: created.messageId,
1484
+ });
1485
+ }
1486
+ if (!interactive) {
1487
+ return deniedCodexAppToolResult('request_card requires an actions block; use send_card.');
1488
+ }
1489
+ const cardId = card.cardId ?? `codex_card_${randomUUID()}`;
1490
+ const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
1491
+ const response = await runtimeRequests.request('card', session.conversationId, {
1492
+ card,
1493
+ turnId: session.currentTurnId ?? undefined,
1494
+ ...(responseRouting.responseUserId
1495
+ ? { responseUserId: responseRouting.responseUserId }
1496
+ : {}),
1497
+ }, {
1498
+ requestId: cardId,
1499
+ expiresAt,
1500
+ signal: session.currentTurnAbortController?.signal,
1501
+ onCreated: () => {
1502
+ session.turnState = 'waiting_input';
1503
+ markTurnProgress(session);
1504
+ upsertTurnBlock(session, {
1505
+ id: `card:${cardId}`,
1506
+ kind: 'input',
1507
+ status: 'pending',
1508
+ title: card.title,
1509
+ summary: card.template ?? 'runtime card',
1510
+ });
1511
+ writeTurn(session);
1512
+ stopVisibleWorkSignal(session);
1513
+ writeCodexStreaming(session, null, 'waiting_input');
1514
+ },
1515
+ });
1516
+ completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
1517
+ resumeTurnFromWaiting(session);
1518
+ return successfulCodexAppToolResult(response);
1519
+ }
1396
1520
  // `disposition` already ruled on the transport (it is checked first, so a
1397
1521
  // wrong transport never reaches the branches above). This repeats the
1398
1522
  // test only to narrow the adapter type for the bridge call.
@@ -1410,9 +1534,13 @@ export async function main() {
1410
1534
  }
1411
1535
  const runtimeCardPayload = runtimeCardRequestPayload(request.method, params);
1412
1536
  if (runtimeCardPayload) {
1413
- const card = parseRuntimeCardV1(runtimeCardPayload);
1414
- if (!card) {
1415
- return { status: 'cancelled', error: 'Invalid canon.card.v1 card' };
1537
+ const validation = validateCard(runtimeCardPayload);
1538
+ const card = validation.card;
1539
+ if (!validation.ok || !card) {
1540
+ return {
1541
+ status: 'cancelled',
1542
+ error: `Invalid canon.card.v1 card: ${validation.errors.join('; ')}`,
1543
+ };
1416
1544
  }
1417
1545
  const cardId = readString(params, 'cardId')
1418
1546
  ?? readString(params, 'itemId')
@@ -1443,6 +1571,7 @@ export async function main() {
1443
1571
  }, {
1444
1572
  requestId: cardId,
1445
1573
  expiresAt,
1574
+ signal: session.currentTurnAbortController?.signal,
1446
1575
  onCreated: () => {
1447
1576
  requestCreated = true;
1448
1577
  session.turnState = 'waiting_input';
@@ -1464,8 +1593,14 @@ export async function main() {
1464
1593
  status: 'submitted',
1465
1594
  ...(cardResult.actionId ? { actionId: cardResult.actionId } : {}),
1466
1595
  ...(cardResult.values ? { values: cardResult.values } : {}),
1596
+ ...(cardResult.respondedBy ? { respondedBy: cardResult.respondedBy } : {}),
1467
1597
  }
1468
- : { status: cardResult.status };
1598
+ : {
1599
+ status: cardResult.status,
1600
+ ...('respondedBy' in cardResult && cardResult.respondedBy
1601
+ ? { respondedBy: cardResult.respondedBy }
1602
+ : {}),
1603
+ };
1469
1604
  requestResolved = true;
1470
1605
  const outcome = buildRuntimeCardOutcome(cardId, response.status, { reason: response.status });
1471
1606
  await sendMessageWithRetry(client, session.conversationId, outcome.text, {
@@ -1535,7 +1670,11 @@ export async function main() {
1535
1670
  },
1536
1671
  },
1537
1672
  turnId: session.currentTurnId ?? undefined,
1538
- }, { requestId: inputId, expiresAt });
1673
+ }, {
1674
+ requestId: inputId,
1675
+ expiresAt,
1676
+ signal: session.currentTurnAbortController?.signal,
1677
+ });
1539
1678
  resumeTurnFromWaiting(session);
1540
1679
  return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
1541
1680
  }
@@ -1554,7 +1693,11 @@ export async function main() {
1554
1693
  ? { responseUserId: responseRouting.responseUserId }
1555
1694
  : {}),
1556
1695
  allowSessionRule: responseRouting.allowSessionRule,
1557
- }, { requestId: approvalId, expiresAt });
1696
+ }, {
1697
+ requestId: approvalId,
1698
+ expiresAt,
1699
+ signal: session.currentTurnAbortController?.signal,
1700
+ });
1558
1701
  resumeTurnFromWaiting(session);
1559
1702
  if (request.method === 'item/permissions/requestApproval') {
1560
1703
  return response.decision === 'allow'
@@ -1595,7 +1738,7 @@ export async function main() {
1595
1738
  ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1596
1739
  : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
1597
1740
  enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', {
1598
- canUseCodexAppTools: input.isOwner,
1741
+ canUseCodexAppTools: input.isOwner || serviceAgentMode,
1599
1742
  requestingUserId: getCodexRequestingUserId(input.message),
1600
1743
  });
1601
1744
  return;
@@ -1642,9 +1785,10 @@ export async function main() {
1642
1785
  });
1643
1786
  const replyContext = replyMedia.replyContext;
1644
1787
  const promptMaterialized = [...replyMedia.materialized, ...materialized];
1645
- const imagePaths = promptMaterialized
1788
+ const discoveredImagePaths = promptMaterialized
1646
1789
  .map((attachment) => getCodexImagePath(attachment))
1647
1790
  .filter((path) => path !== null);
1791
+ const imagePaths = selectCodexNativeImagePaths(discoveredImagePaths, nativeVisionEnabled);
1648
1792
  const mediaAddDirs = uniqueStrings(promptMaterialized.map((attachment) => dirname(attachment.path)));
1649
1793
  const participantContext = hydrated.participantContext;
1650
1794
  const autoReply = input.turnDispatch?.kind === 'run_turn'
@@ -1698,6 +1842,7 @@ export async function main() {
1698
1842
  if (session.running && deliveryIntent === 'interrupt') {
1699
1843
  enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message));
1700
1844
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
1845
+ session.currentTurnAbortController?.abort(new Error('Codex turn interrupted by a newer message'));
1701
1846
  await session.adapter.interrupt().catch(() => { });
1702
1847
  clearStreaming(input.conversationId);
1703
1848
  typingSignals.clear(input.conversationId).catch(() => { });
@@ -1734,6 +1879,7 @@ export async function main() {
1734
1879
  session.currentTurnOpenedAt = Date.now();
1735
1880
  session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
1736
1881
  session.currentTurnCanUseCodexAppTools = nextTurn.canUseCodexAppTools === true;
1882
+ session.currentTurnAbortController = new AbortController();
1737
1883
  // A continuation prompt (a plan-review result) carries none, and keeps the
1738
1884
  // conversation's last answer rather than silently reverting to verbose.
1739
1885
  session.turnVerbosity = nextTurn.turnVerbosity ?? session.turnVerbosity;
@@ -2135,6 +2281,8 @@ export async function main() {
2135
2281
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
2136
2282
  }
2137
2283
  finally {
2284
+ session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
2285
+ session.currentTurnAbortController = null;
2138
2286
  persistInboundRecoveryCursor(session.conversationId, nextTurn.sourceMessageId);
2139
2287
  if (session.pendingDroppedRecoveryCursor) {
2140
2288
  persistInboundRecoveryCursor(session.conversationId, session.pendingDroppedRecoveryCursor);
@@ -2184,10 +2332,10 @@ export async function main() {
2184
2332
  }
2185
2333
  }
2186
2334
  let streamConnected = false;
2187
- const hostAvailableExecutionModes = [
2188
- ...EXECUTION_ENVIRONMENT_MODES,
2189
- ];
2190
- const codexPermissionEnvelope = deriveCodexPermissionEnvelope(args);
2335
+ const hostAvailableExecutionModes = serviceAgentMode
2336
+ ? ['locked']
2337
+ : [...EXECUTION_ENVIRONMENT_MODES];
2338
+ const codexPermissionEnvelope = deriveCodexPermissionEnvelope(serviceAgentMode ? { sandbox: 'read-only' } : args);
2191
2339
  const configuredCodexEffort = readCodexConfiguredEffort(args.config ?? []);
2192
2340
  let codexModels = [];
2193
2341
  let codexModelOptions = buildCodexModelOptions(codexModels, args.model);
@@ -2345,6 +2493,7 @@ export async function main() {
2345
2493
  rememberDroppedRecoveryCursor(session, droppedPrompts);
2346
2494
  }
2347
2495
  if (session.running) {
2496
+ session.currentTurnAbortController?.abort(new Error(`Codex turn interrupted by ${type}`));
2348
2497
  await session.adapter.interrupt();
2349
2498
  }
2350
2499
  session.turnState = 'interrupted';
@@ -2666,6 +2815,9 @@ export async function main() {
2666
2815
  controlPoller.stop();
2667
2816
  clearInterval(heartbeat);
2668
2817
  clearInterval(idleCheck);
2818
+ for (const session of sessions.values()) {
2819
+ session.currentTurnAbortController?.abort(new Error('Codex host shutting down'));
2820
+ }
2669
2821
  runtimeRequests.dispose();
2670
2822
  stream.stop();
2671
2823
  await runtimeState.clearAgentRuntime().catch(() => { });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.25.1",
3
+ "version": "0.26.1",
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",
24
+ "prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk ../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",
@@ -31,7 +31,8 @@
31
31
  "dependencies": {
32
32
  "@canonmsg/agent-sdk": "^8.3.0",
33
33
  "@canonmsg/coding-agent-host": "^0.5.0",
34
- "@canonmsg/core": "^10.2.0"
34
+ "@canonmsg/core": "^10.3.1",
35
+ "@canonmsg/rich-cards": "^0.10.0"
35
36
  },
36
37
  "engines": {
37
38
  "node": ">=18.0.0"