@canonmsg/codex-plugin 0.26.1 → 0.26.2

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.
package/dist/host.d.ts CHANGED
@@ -36,6 +36,19 @@ 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
+ };
39
52
  /** Conservative fallback used only when native app-server discovery is unavailable. */
40
53
  export declare const CODEX_EFFORT_OPTIONS: readonly CodexControlOption[];
41
54
  export declare const CODEX_SESSION_CONFIG_FIELDS: readonly ["permissionMode", "effort"];
@@ -57,6 +70,7 @@ export declare function buildCodexRuntimeDescriptor(input: {
57
70
  supportsCompact?: boolean;
58
71
  supportsRichCards?: boolean;
59
72
  skills?: ReadonlyArray<CodexSkillMetadata>;
73
+ serviceAgentMode?: boolean;
60
74
  }): CanonRuntimeDescriptor;
61
75
  export declare function buildCodexTurnResponseRouting(input: {
62
76
  requestingUserId?: string | null;
@@ -73,6 +87,7 @@ export declare function getCodexRequestingUserId(message: {
73
87
  export declare function resolveSessionExecutionMode(config: {
74
88
  executionMode?: ExecutionEnvironmentMode;
75
89
  } | null | undefined, serviceAgentMode?: boolean): ExecutionEnvironmentMode;
90
+ export declare function resolveCodexSessionConfig<T extends Record<string, unknown>>(config: T | null | undefined, serviceAgentMode?: boolean): T | null;
76
91
  export declare function resolveWorkspaceCwd(config: {
77
92
  workspaceId?: string;
78
93
  retiredWorkspaceConfig?: boolean;
@@ -95,6 +110,19 @@ export declare function resolveCodexEffectiveRuntimePolicy(input: {
95
110
  environment: Pick<PreparedExecutionEnvironment, 'baseCwd' | 'mode'>;
96
111
  serviceAgentMode?: boolean;
97
112
  }): CodexEffectiveRuntimePolicy;
113
+ export declare function resolveCodexPlanCommand(input: {
114
+ content: string;
115
+ requestedPlanMode: boolean;
116
+ useAppServer: boolean;
117
+ serviceAgentMode: boolean;
118
+ }): {
119
+ planMode: boolean;
120
+ content: string;
121
+ };
122
+ export declare function isCodexPlanApprovalReply(metadata: unknown, serviceAgentMode?: boolean): metadata is Record<string, unknown> & {
123
+ type: 'plan_approval_reply';
124
+ decision: string;
125
+ };
98
126
  /**
99
127
  * `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
100
128
  * per-conversation-type default".
package/dist/host.js CHANGED
@@ -78,6 +78,20 @@ 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;
82
96
  // IDLE_TIMEOUT_MS (30 minutes) is shared with the other coding-agent hosts.
83
97
  const HEARTBEAT_MS = 30_000;
@@ -138,6 +152,7 @@ export function buildCodexSkillCommands(skills) {
138
152
  });
139
153
  }
140
154
  export function buildCodexRuntimeDescriptor(input) {
155
+ const serviceAgentMode = input.serviceAgentMode === true;
141
156
  const commands = [
142
157
  {
143
158
  id: 'runtime-status',
@@ -157,7 +172,7 @@ export function buildCodexRuntimeDescriptor(input) {
157
172
  RUNTIME_STOP_ACTION,
158
173
  RUNTIME_STOP_AND_DROP_ACTION,
159
174
  ];
160
- if (input.supportsCompact) {
175
+ if (input.supportsCompact && !serviceAgentMode) {
161
176
  commands.push({
162
177
  id: 'codex-compact-context',
163
178
  label: 'Compact',
@@ -177,20 +192,22 @@ export function buildCodexRuntimeDescriptor(input) {
177
192
  }
178
193
  const descriptor = buildFirstPartyCodingRuntimeDescriptor({
179
194
  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,
195
+ models: serviceAgentMode ? [] : input.models,
196
+ workspaces: serviceAgentMode ? [] : input.workspaces,
197
+ workspaceRoots: serviceAgentMode ? undefined : input.workspaceRoots,
198
+ executionModes: serviceAgentMode ? [] : input.executionModes,
199
+ permissionModes: serviceAgentMode ? [] : input.permissionModes,
200
+ defaultPermissionMode: serviceAgentMode ? undefined : input.defaultPermissionMode,
186
201
  permissionModeLabel: 'Execution policy',
187
202
  modelLiveBehavior: 'next_turn',
188
- effortOptions: input.effortOptions ?? [...CODEX_EFFORT_OPTIONS],
189
- defaultEffort: input.defaultEffort ?? 'medium',
203
+ effortOptions: serviceAgentMode
204
+ ? []
205
+ : input.effortOptions ?? [...CODEX_EFFORT_OPTIONS],
206
+ defaultEffort: serviceAgentMode ? undefined : input.defaultEffort ?? 'medium',
190
207
  effortLiveBehavior: 'next_turn',
191
208
  presentation: input.presentation,
192
209
  streamingTextMode: 'snapshot',
193
- ...(input.supportsPlanMode
210
+ ...(input.supportsPlanMode && !serviceAgentMode
194
211
  ? {
195
212
  turnModes: [
196
213
  {
@@ -220,7 +237,7 @@ export function buildCodexRuntimeDescriptor(input) {
220
237
  rich: {
221
238
  schema: 'canon.card.v1',
222
239
  lifecycle: 'blocking_requires_action',
223
- responder: 'agent_owner',
240
+ ...(serviceAgentMode ? {} : { responder: 'agent_owner' }),
224
241
  result: 'action_or_values',
225
242
  maxTimeoutMs: 30 * 60_000,
226
243
  blockKinds: ['summary', 'metricGrid', 'chart', 'table', 'list', 'callout', 'actions'],
@@ -231,6 +248,13 @@ export function buildCodexRuntimeDescriptor(input) {
231
248
  }
232
249
  : {}),
233
250
  });
251
+ if (serviceAgentMode) {
252
+ const { runtimeControls: _runtimeControls, ...serviceDescriptor } = descriptor;
253
+ return {
254
+ ...serviceDescriptor,
255
+ coreControls: [],
256
+ };
257
+ }
234
258
  if (input.models.length > 0) {
235
259
  return descriptor;
236
260
  }
@@ -269,6 +293,9 @@ export function resolveSessionExecutionMode(config, serviceAgentMode = false) {
269
293
  return config.executionMode;
270
294
  throw new ExecutionEnvironmentError('Session config is missing an execution mode.', 'Choose Isolated worktree or Use shared project before starting this coding session.');
271
295
  }
296
+ export function resolveCodexSessionConfig(config, serviceAgentMode = false) {
297
+ return serviceAgentMode ? null : config ?? null;
298
+ }
272
299
  export function resolveWorkspaceCwd(config, serviceAgentMode = false) {
273
300
  return resolveHostWorkspaceCwd({
274
301
  workspaceOptions,
@@ -311,6 +338,9 @@ export function resolveCodexEffectiveRuntimePolicy(input) {
311
338
  baseCwd: input.environment.baseCwd,
312
339
  executionMode: input.environment.mode,
313
340
  permissionMode: permissionMode ?? null,
341
+ ...(input.serviceAgentMode
342
+ ? { serviceAgentMode: true, model: model ?? null }
343
+ : {}),
314
344
  sandbox,
315
345
  // `--ask-for-approval` is rejected at startup, so this was always null.
316
346
  // The key must stay: it is hashed into every persisted thread fingerprint.
@@ -400,6 +430,14 @@ function parsePlanCommand(content) {
400
430
  content: rest || 'Please inspect the request and propose a plan before making changes.',
401
431
  };
402
432
  }
433
+ export function resolveCodexPlanCommand(input) {
434
+ if (!input.useAppServer || input.serviceAgentMode) {
435
+ return { planMode: false, content: input.content };
436
+ }
437
+ return input.requestedPlanMode
438
+ ? { planMode: true, content: input.content }
439
+ : parsePlanCommand(input.content);
440
+ }
403
441
  function mapCodexQuestions(value) {
404
442
  if (!Array.isArray(value))
405
443
  return undefined;
@@ -453,6 +491,12 @@ function mapCodexQuestions(value) {
453
491
  function isRecord(value) {
454
492
  return Boolean(value && typeof value === 'object' && !Array.isArray(value));
455
493
  }
494
+ export function isCodexPlanApprovalReply(metadata, serviceAgentMode = false) {
495
+ return !serviceAgentMode
496
+ && isRecord(metadata)
497
+ && metadata.type === 'plan_approval_reply'
498
+ && typeof metadata.decision === 'string';
499
+ }
456
500
  function readString(record, key) {
457
501
  const value = record[key];
458
502
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
@@ -1205,7 +1249,7 @@ export async function main() {
1205
1249
  evictOldestIdle();
1206
1250
  }
1207
1251
  const creation = (async () => {
1208
- const config = await loadSessionConfig(conversationId, agentId, rtdb);
1252
+ const config = resolveCodexSessionConfig(await loadSessionConfig(conversationId, agentId, rtdb), serviceAgentMode);
1209
1253
  const sessionExecutionMode = resolveSessionExecutionMode(config, serviceAgentMode);
1210
1254
  const workspaceCwd = resolveWorkspaceCwd(config, serviceAgentMode);
1211
1255
  const environment = prepareConversationEnvironment({
@@ -1718,9 +1762,14 @@ export async function main() {
1718
1762
  persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1719
1763
  return;
1720
1764
  }
1721
- if (isRecord(input.message.metadata)
1722
- && input.message.metadata.type === 'plan_approval_reply'
1723
- && typeof input.message.metadata.decision === 'string') {
1765
+ if (input.message.metadata?.type === 'plan_approval_reply') {
1766
+ if (!isCodexPlanApprovalReply(input.message.metadata, serviceAgentMode)) {
1767
+ // A service agent never advertises or enters plan mode. Consume stale
1768
+ // replies left by an older coding descriptor instead of turning them
1769
+ // into hidden plan/implementation prompts after an upgrade or restart.
1770
+ persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1771
+ return;
1772
+ }
1724
1773
  const planId = readString(input.message.metadata, 'planId');
1725
1774
  if (planId && runtimeRequests.handleMessage(input.conversationId, {
1726
1775
  senderId: input.message.senderId,
@@ -1758,11 +1807,12 @@ export async function main() {
1758
1807
  const renderedContent = renderInboundContent(input.message, materialized);
1759
1808
  const turnMetadata = normalizeTurnMetadata(input.message.metadata);
1760
1809
  const requestedPlanMode = turnMetadata?.requestedTurnMode === 'plan';
1761
- const planCommand = useAppServer
1762
- ? requestedPlanMode
1763
- ? { planMode: true, content: renderedContent }
1764
- : parsePlanCommand(renderedContent)
1765
- : { planMode: false, content: renderedContent };
1810
+ const planCommand = resolveCodexPlanCommand({
1811
+ content: renderedContent,
1812
+ requestedPlanMode,
1813
+ useAppServer,
1814
+ serviceAgentMode,
1815
+ });
1766
1816
  const content = planCommand.content;
1767
1817
  const hydrated = await loadHydratedInboundContext({
1768
1818
  conversationId: input.conversationId,
@@ -2354,14 +2404,18 @@ export async function main() {
2354
2404
  });
2355
2405
  let codexSkills = [];
2356
2406
  const buildCurrentRuntimeDescriptor = () => ({
2357
- defaultWorkspaceId: workspaceOptions[0]?.id,
2407
+ ...(serviceAgentMode
2408
+ ? {}
2409
+ : {
2410
+ defaultWorkspaceId: workspaceOptions[0]?.id,
2411
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
2412
+ availableExecutionModes: hostAvailableExecutionModes,
2413
+ availablePermissionModes: [...codexPermissionEnvelope.availablePermissionModes],
2414
+ ...(codexPermissionEnvelope.defaultPermissionMode
2415
+ ? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
2416
+ : {}),
2417
+ }),
2358
2418
  ...(codexDefaultModel ? { defaultModel: codexDefaultModel } : {}),
2359
- availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
2360
- availableExecutionModes: hostAvailableExecutionModes,
2361
- availablePermissionModes: [...codexPermissionEnvelope.availablePermissionModes],
2362
- ...(codexPermissionEnvelope.defaultPermissionMode
2363
- ? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
2364
- : {}),
2365
2419
  runtimeDescriptor: buildCodexRuntimeDescriptor({
2366
2420
  models: codexModelOptions,
2367
2421
  effortOptions: codexEffortOptions,
@@ -2376,6 +2430,7 @@ export async function main() {
2376
2430
  supportsCompact: useAppServer,
2377
2431
  supportsRichCards: useAppServer,
2378
2432
  skills: codexSkills,
2433
+ serviceAgentMode,
2379
2434
  }),
2380
2435
  });
2381
2436
  let runtimeDescriptor = buildCurrentRuntimeDescriptor();
@@ -2421,6 +2476,11 @@ export async function main() {
2421
2476
  const session = sessions.get(conversationId);
2422
2477
  if (!session || session.closed)
2423
2478
  return;
2479
+ if (serviceAgentMode) {
2480
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring user session controls for service-agent runtime`);
2481
+ writeState(session);
2482
+ return;
2483
+ }
2424
2484
  let modelChanged = false;
2425
2485
  if (control.model && control.model !== session.state.model) {
2426
2486
  if (codexModelOptions.length > 0 && !codexModelOptions.some((option) => option.value === control.model)) {
@@ -2577,10 +2637,27 @@ export async function main() {
2577
2637
  workspaceOptions,
2578
2638
  defaultCwd: workingDir,
2579
2639
  extraSessionConfigFields: CODEX_SESSION_CONFIG_FIELDS,
2580
- liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
2640
+ liveSessionConfigByConversation: new Map(Array.from(serviceAgentMode ? knownConversationIds : sessions.keys()).map((conversationId) => {
2641
+ const session = sessions.get(conversationId);
2642
+ if (serviceAgentMode) {
2643
+ return [
2644
+ conversationId,
2645
+ buildCodexServiceSnapshotConfig({
2646
+ model: codexDefaultModel ?? session?.state.model ?? undefined,
2647
+ permissionMode: codexPermissionEnvelope.defaultPermissionMode,
2648
+ effort: codexDefaultEffort ?? session?.state.effort ?? undefined,
2649
+ workspaceId: resolveWorkspaceIdForBaseCwd(workingDir) ?? undefined,
2650
+ }),
2651
+ ];
2652
+ }
2653
+ if (!session) {
2654
+ // Non-service snapshots are published only for live sessions,
2655
+ // but keep this branch total if that invariant changes.
2656
+ return [conversationId, {}];
2657
+ }
2581
2658
  const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
2582
2659
  return [
2583
- session.conversationId,
2660
+ conversationId,
2584
2661
  buildCodexLiveSessionConfig({
2585
2662
  model: session.state.model,
2586
2663
  permissionMode: session.state.permissionMode,
@@ -2749,7 +2826,7 @@ export async function main() {
2749
2826
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovery cursor was not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; replaying the bounded recent window`);
2750
2827
  }
2751
2828
  for (const message of recovered.messages) {
2752
- const isPlanReply = message.metadata?.type === 'plan_approval_reply';
2829
+ const isPlanReply = isCodexPlanApprovalReply(message.metadata, serviceAgentMode);
2753
2830
  if (!isPlanReply && !shouldTriggerAgentTurn({
2754
2831
  senderType: message.senderType,
2755
2832
  metadata: message.metadata,
@@ -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.26.2",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",