@canonmsg/codex-plugin 0.28.0 → 0.29.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.
@@ -21,6 +21,8 @@ export interface CodexAppToolRuntime {
21
21
  currentThreadId: string | null;
22
22
  currentCwd: string;
23
23
  workspaces: ReadonlyArray<CodexAppToolWorkspace>;
24
+ /** Whether detached work may see Canon's autonomous communication surface. */
25
+ communicationEnabled?: boolean;
24
26
  model?: string | null;
25
27
  effort?: string | null;
26
28
  }
@@ -265,9 +265,11 @@ export const CODEX_SERVICE_AGENT_DYNAMIC_TOOLS = [
265
265
  ];
266
266
  /** Closed agents are not shown the optional outbound communication surface. */
267
267
  export function filterCodexCommunicationTools(tools, outboundPolicy) {
268
- return outboundPolicy === 'closed'
269
- ? tools.filter((entry) => entry.name !== CODEX_COMMUNICATE_TOOL_NAME)
270
- : tools;
268
+ const communicationEnabled = outboundPolicy === 'open'
269
+ || outboundPolicy === 'approval-required';
270
+ return communicationEnabled
271
+ ? tools
272
+ : tools.filter((entry) => entry.name !== CODEX_COMMUNICATE_TOOL_NAME);
271
273
  }
272
274
  /**
273
275
  * Tools exposed to detached Codex threads created through the bridge. Canon
@@ -465,7 +467,7 @@ async function createThread(runtime, args) {
465
467
  const started = await runtime.adapter.requestAppServer('thread/start', {
466
468
  cwd,
467
469
  ...(readString(args, 'model') ?? runtime.model ? { model: readString(args, 'model') ?? runtime.model } : {}),
468
- dynamicTools: CODEX_DETACHED_THREAD_DYNAMIC_TOOLS,
470
+ dynamicTools: filterCodexCommunicationTools(CODEX_DETACHED_THREAD_DYNAMIC_TOOLS, runtime.communicationEnabled === true ? 'open' : null),
469
471
  experimentalRawEvents: false,
470
472
  persistExtendedHistory: true,
471
473
  });
@@ -5,18 +5,14 @@
5
5
  * codex host characterization profile (the "codex host profile" describe
6
6
  * block in core's control-poller.test.ts is the contract):
7
7
  *
8
- * - Keys: `session` + `signal`, plus `primitive` when a primitive handler is
9
- * configured; read sequentially per conversation, conversations polled one
10
- * at a time.
8
+ * - Keys: `signal`, plus `primitive` when a primitive handler is configured;
9
+ * read sequentially per conversation, conversations polled one at a time.
11
10
  * - Cadence: immediate first cycle, then active/idle delays + jitter with
12
11
  * the activity probe sampled BEFORE the cycle runs.
13
- * - Session controls are always consumed once newer (default consume), even
14
- * when no live session applied them; signals are NOT consumed when the
15
- * handler throws (consumeOnError stays false).
16
12
  * - Dedupe is primed eagerly via `poller.baseline([conversationId])` at
17
- * session creation — the second legacy poll site.
13
+ * session creation.
18
14
  */
19
- import { ControlChannelPoller, type ControlChannelRTDB, type ControlHandlerResult, type ControlPollerError, type ControlPrimitiveEvent, type ControlSessionEvent, type ControlSignalEvent } from '@canonmsg/core';
15
+ import { ControlChannelPoller, type ControlChannelRTDB, type ControlHandlerResult, type ControlPollerError, type ControlPrimitiveEvent, type ControlSignalEvent } from '@canonmsg/core';
20
16
  export declare const CONTROL_POLL_MS = 2000;
21
17
  export declare const IDLE_CONTROL_POLL_MS = 10000;
22
18
  export declare const CONTROL_POLL_JITTER_MS = 1000;
@@ -28,12 +24,6 @@ export interface CodexControlChannelInput {
28
24
  conversationIds: () => Iterable<string>;
29
25
  /** Sampled before each cycle to choose the active vs idle delay. */
30
26
  hasActiveWork: () => boolean;
31
- /**
32
- * Applies a newer `/session` control node. The node is always consumed
33
- * after the handler resolves; a thrown error leaves it in place (with
34
- * dedupe already advanced, so it is never retried).
35
- */
36
- onSessionControl: (event: ControlSessionEvent) => Promise<ControlHandlerResult> | ControlHandlerResult;
37
27
  /**
38
28
  * Handles a newer `/signal` node. Return `{ consume: false }` to leave the
39
29
  * node in place; thrown errors also leave it (consumeOnError stays false).
@@ -5,16 +5,12 @@
5
5
  * codex host characterization profile (the "codex host profile" describe
6
6
  * block in core's control-poller.test.ts is the contract):
7
7
  *
8
- * - Keys: `session` + `signal`, plus `primitive` when a primitive handler is
9
- * configured; read sequentially per conversation, conversations polled one
10
- * at a time.
8
+ * - Keys: `signal`, plus `primitive` when a primitive handler is configured;
9
+ * read sequentially per conversation, conversations polled one at a time.
11
10
  * - Cadence: immediate first cycle, then active/idle delays + jitter with
12
11
  * the activity probe sampled BEFORE the cycle runs.
13
- * - Session controls are always consumed once newer (default consume), even
14
- * when no live session applied them; signals are NOT consumed when the
15
- * handler throws (consumeOnError stays false).
16
12
  * - Dedupe is primed eagerly via `poller.baseline([conversationId])` at
17
- * session creation — the second legacy poll site.
13
+ * session creation.
18
14
  */
19
15
  import { ControlChannelPoller, } from '@canonmsg/core';
20
16
  export const CONTROL_POLL_MS = 2_000;
@@ -36,9 +32,6 @@ export function createCodexControlPoller(input) {
36
32
  pollOnStart: true,
37
33
  conversationConcurrency: 'sequential',
38
34
  handlers: {
39
- session: {
40
- handle: input.onSessionControl,
41
- },
42
35
  signal: {
43
36
  handle: input.onSignal,
44
37
  },
package/dist/host.d.ts CHANGED
@@ -21,38 +21,9 @@ export declare function buildCodexInitialSessionState(input: {
21
21
  permissionMode?: string;
22
22
  effort?: string | null;
23
23
  }): HostSessionState;
24
- export declare function buildCodexLiveSessionConfig(input: {
25
- model?: string;
26
- permissionMode?: string;
27
- effort?: string;
28
- workspaceId?: string | null;
29
- executionMode: ExecutionEnvironmentMode;
30
- executionBranch?: string | null;
31
- }): {
32
- executionMode: ExecutionEnvironmentMode;
33
- executionBranch: string | null;
34
- workspaceId?: string | undefined;
35
- effort?: string | undefined;
36
- permissionMode?: string | undefined;
37
- model?: string | undefined;
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
24
  export declare function createCodexRecoveryCheckpointTracker(persist: (messageId: string) => boolean): RecoveryCheckpointTracker;
53
25
  /** Conservative fallback used only when native app-server discovery is unavailable. */
54
26
  export declare const CODEX_EFFORT_OPTIONS: readonly CodexControlOption[];
55
- export declare const CODEX_SESSION_CONFIG_FIELDS: readonly ["permissionMode", "effort"];
56
27
  export declare function buildCodexSkillCommands(skills: ReadonlyArray<CodexSkillMetadata>): CanonRuntimeCommandDescriptor[];
57
28
  export declare function buildCodexRuntimeDescriptor(input: {
58
29
  models: CodexControlOption[];
@@ -86,14 +57,8 @@ export declare function getCodexRequestingUserId(message: {
86
57
  senderId: string;
87
58
  senderType?: 'human' | 'ai_agent';
88
59
  }): string | null;
89
- export declare function resolveSessionExecutionMode(config: {
90
- executionMode?: ExecutionEnvironmentMode;
91
- } | null | undefined, serviceAgentMode?: boolean, defaultExecutionMode?: ExecutionEnvironmentMode): ExecutionEnvironmentMode;
92
- export declare function resolveCodexSessionConfig<T extends Record<string, unknown>>(config: T | null | undefined, serviceAgentMode?: boolean): T | null;
93
- export declare function resolveWorkspaceCwd(config: {
94
- workspaceId?: string;
95
- retiredWorkspaceConfig?: boolean;
96
- } | null, serviceAgentMode?: boolean): string;
60
+ export declare function resolveSessionExecutionMode(serviceAgentMode?: boolean, defaultExecutionMode?: ExecutionEnvironmentMode): ExecutionEnvironmentMode;
61
+ export declare function resolveWorkspaceCwd(): string;
97
62
  interface CodexEffectiveRuntimePolicy {
98
63
  model?: string;
99
64
  permissionMode?: string;
package/dist/host.js CHANGED
@@ -6,7 +6,7 @@ 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, 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, 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';
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, releaseConversationEnvironment, resolveLocalRuntimeSessionState, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
10
10
  import { validateCard } from '@canonmsg/rich-cards';
11
11
  import { CodexConversationAdapter, } from './adapter.js';
12
12
  import { CodexAppServerAdapter, } from './app-server-adapter.js';
@@ -69,36 +69,14 @@ export function buildCodexInitialSessionState(input) {
69
69
  state: 'idle',
70
70
  };
71
71
  }
72
- export function buildCodexLiveSessionConfig(input) {
73
- return {
74
- ...(input.model ? { model: input.model } : {}),
75
- ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}),
76
- ...(input.effort ? { effort: input.effort } : {}),
77
- ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
78
- executionMode: input.executionMode,
79
- executionBranch: input.executionBranch ?? null,
80
- };
81
- }
82
- export function buildCodexServiceSnapshotConfig(input) {
83
- // Keep every setup key present, including keys whose host value is
84
- // undefined. `publishHostSessionSnapshots` merges this over any persisted
85
- // coding config, so an absent host value clears rather than resurrects a
86
- // stale member-selected value.
87
- return {
88
- model: input.model,
89
- permissionMode: input.permissionMode,
90
- effort: input.effort,
91
- workspaceId: input.workspaceId,
92
- executionMode: 'locked',
93
- executionBranch: null,
94
- };
95
- }
96
72
  const MAX_SESSIONS = 12;
97
73
  export function createCodexRecoveryCheckpointTracker(persist) {
98
74
  return createRecoveryCheckpointTracker(persist);
99
75
  }
100
76
  // IDLE_TIMEOUT_MS (30 minutes) is shared with the other coding-agent hosts.
101
77
  const HEARTBEAT_MS = 30_000;
78
+ const LOCAL_CONFIGURATION_REQUIRED_MESSAGE = 'This agent needs local runtime configuration before it can start this conversation. '
79
+ + 'Its operator must configure it and retry.';
102
80
  /** How Codex says it finished the work but Canon would not take the answer. */
103
81
  const CODEX_UNDELIVERABLE_FINAL_WORDING = {
104
82
  lead: 'The Codex host completed the turn, but Canon could not deliver the reply',
@@ -113,18 +91,13 @@ const CODEX_RUNTIME_CAPABILITIES = {
113
91
  supportsNonFinalPermanentMessages: false,
114
92
  };
115
93
  let workingDir = process.cwd();
116
- /**
117
- * Agent-developer setting, resolved once at startup. Deliberately NOT read from
118
- * `/session-config`: that path is the USER's per-conversation control plane,
119
- * and owner ruling 5 puts turn verbosity outside user control.
120
- */
94
+ /** Turn verbosity is local agent-developer configuration, resolved at startup. */
121
95
  let configuredTurnVerbosity = null;
122
96
  let workspaceOptions = [];
123
97
  let workspaceRoots = [];
124
98
  let workspaceRootMetadata = [];
125
99
  /** Conservative fallback used only when native app-server discovery is unavailable. */
126
100
  export const CODEX_EFFORT_OPTIONS = FALLBACK_CODEX_EFFORT_OPTIONS;
127
- export const CODEX_SESSION_CONFIG_FIELDS = ['permissionMode', 'effort'];
128
101
  const MAX_CODEX_SKILL_COMMAND_CHOICES = 50;
129
102
  export function buildCodexSkillCommands(skills) {
130
103
  return skills
@@ -283,18 +256,10 @@ export function getCodexRequestingUserId(message) {
283
256
  async function publishAgentRuntime(agentId, runtime, rtdb) {
284
257
  await publishHostAgentRuntime(agentId, 'codex', runtime, rtdb);
285
258
  }
286
- async function loadSessionConfig(conversationId, agentId, rtdb) {
287
- return loadHostSessionConfig({
288
- conversationId,
289
- agentId,
290
- rtdb,
291
- extraStringFields: CODEX_SESSION_CONFIG_FIELDS,
292
- });
293
- }
294
- export function resolveSessionExecutionMode(config, serviceAgentMode = false, defaultExecutionMode = 'worktree') {
259
+ export function resolveSessionExecutionMode(serviceAgentMode = false, defaultExecutionMode = 'worktree') {
295
260
  if (serviceAgentMode)
296
261
  return 'locked';
297
- return config?.executionMode ?? defaultExecutionMode;
262
+ return defaultExecutionMode;
298
263
  }
299
264
  function resolveConfiguredDefaultExecutionMode(value) {
300
265
  if (value == null || value === '')
@@ -303,13 +268,10 @@ function resolveConfiguredDefaultExecutionMode(value) {
303
268
  return value;
304
269
  throw new Error('--default-execution-mode must be worktree or locked');
305
270
  }
306
- export function resolveCodexSessionConfig(config, serviceAgentMode = false) {
307
- return serviceAgentMode ? null : config ?? null;
308
- }
309
- export function resolveWorkspaceCwd(config, serviceAgentMode = false) {
310
- return resolveHostWorkspaceCwd({
271
+ export function resolveWorkspaceCwd() {
272
+ return resolveConfiguredWorkspaceCwd({
311
273
  workspaceOptions,
312
- config: serviceAgentMode ? null : config,
274
+ workspaceId: workspaceOptions[0]?.id,
313
275
  defaultCwd: workingDir,
314
276
  });
315
277
  }
@@ -736,7 +698,7 @@ export async function main() {
736
698
  }
737
699
  console.error(`[canon-codex] Authenticated as ${agentId}`);
738
700
  }
739
- const codexDynamicTools = filterCodexCommunicationTools(baseCodexDynamicTools, outboundPolicy);
701
+ let codexDynamicTools = filterCodexCommunicationTools(baseCodexDynamicTools, outboundPolicy);
740
702
  // Shared poll/timeout engine. Built-in `input`/`card` descriptors own
741
703
  // create+poll (codex passes native/responder policy via payload/options).
742
704
  // Approval keeps codex's own resolution shape (no owner-authored outcome
@@ -930,30 +892,8 @@ export async function main() {
930
892
  });
931
893
  }
932
894
  function writeState(session) {
933
- const appliedAt = Date.now();
934
- const controlState = {};
935
- if (session.state.model !== undefined) {
936
- controlState.model = { value: session.state.model, source: 'applied', appliedAt };
937
- }
938
- if (session.state.permissionMode !== undefined) {
939
- controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
940
- }
941
- if (session.state.effort !== undefined) {
942
- controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
943
- }
944
895
  runtimeState.writeSessionState(session.conversationId, {
945
896
  lastError: session.state.lastError,
946
- model: session.state.model,
947
- permissionMode: session.state.permissionMode,
948
- effort: session.state.effort,
949
- controlState,
950
- cwd: session.cwd,
951
- executionMode: session.environment.mode,
952
- ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
953
- ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
954
- ...(resolveExecutionFallbackReason(session.environment)
955
- ? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
956
- : {}),
957
897
  hostMode: true,
958
898
  clientType: 'codex',
959
899
  isActive: true,
@@ -1224,6 +1164,7 @@ export async function main() {
1224
1164
  session.currentTurnOpenedAt = null;
1225
1165
  session.currentTurnUpdatedAt = null;
1226
1166
  session.currentTurnCanUseCodexAppTools = false;
1167
+ session.currentReplyAuthority = null;
1227
1168
  session.currentTurnSilenced = false;
1228
1169
  session.lastAcceptedIntent = null;
1229
1170
  session.resetRequested = false;
@@ -1261,20 +1202,47 @@ export async function main() {
1261
1202
  evictOldestIdle();
1262
1203
  }
1263
1204
  const creation = (async () => {
1264
- const config = resolveCodexSessionConfig(await loadSessionConfig(conversationId, agentId, rtdb), serviceAgentMode);
1265
- const sessionExecutionMode = resolveSessionExecutionMode(config, serviceAgentMode, defaultExecutionMode);
1266
- const workspaceCwd = resolveWorkspaceCwd(config, serviceAgentMode);
1267
- const environment = prepareConversationEnvironment({
1205
+ const sessionExecutionMode = resolveSessionExecutionMode(serviceAgentMode, defaultExecutionMode);
1206
+ const workspaceCwd = resolveWorkspaceCwd();
1207
+ let environment = prepareConversationEnvironment({
1268
1208
  agentId,
1269
1209
  conversationId,
1270
1210
  workspaceCwd,
1271
1211
  allowWorktrees: sessionExecutionMode === 'worktree',
1272
1212
  });
1273
1213
  try {
1214
+ const persistedMapping = resolveLocalRuntimeSessionState(runtimeId, {
1215
+ conversationId,
1216
+ baseCwd: environment.baseCwd,
1217
+ executionMode: environment.mode,
1218
+ resumeField: 'threadId',
1219
+ configuredBaseCwds: workspaceOptions.map((workspace) => workspace.cwd),
1220
+ availableExecutionModes: hostAvailableExecutionModes,
1221
+ });
1222
+ if (persistedMapping.status === 'configuration_required') {
1223
+ throw new ExecutionEnvironmentError(persistedMapping.message, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
1224
+ }
1225
+ if (persistedMapping.status === 'adopted'
1226
+ && (persistedMapping.state.baseCwd !== environment.baseCwd
1227
+ || persistedMapping.state.executionMode !== environment.mode)) {
1228
+ const restoredEnvironment = prepareConversationEnvironment({
1229
+ agentId,
1230
+ conversationId,
1231
+ workspaceCwd: persistedMapping.state.baseCwd,
1232
+ allowWorktrees: persistedMapping.state.executionMode === 'worktree',
1233
+ });
1234
+ if (restoredEnvironment.mode !== persistedMapping.state.executionMode) {
1235
+ releaseConversationEnvironment(restoredEnvironment);
1236
+ throw new ExecutionEnvironmentError(`Conversation ${conversationId} requires local execution mode ${persistedMapping.state.executionMode}, but workspace ${persistedMapping.state.baseCwd} can only be opened in ${restoredEnvironment.mode} mode.`, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
1237
+ }
1238
+ releaseConversationEnvironment(environment);
1239
+ environment = restoredEnvironment;
1240
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Restoring saved local runtime → ${environment.mode} (${environment.baseCwd})`);
1241
+ }
1274
1242
  const sessionCwd = environment.cwd;
1275
1243
  const policy = resolveCodexEffectiveRuntimePolicy({
1276
1244
  args,
1277
- config,
1245
+ config: null,
1278
1246
  permissionEnvelope: codexPermissionEnvelope,
1279
1247
  environment,
1280
1248
  serviceAgentMode,
@@ -1283,12 +1251,17 @@ export async function main() {
1283
1251
  if (modelGuard) {
1284
1252
  throw new ExecutionEnvironmentError(modelGuard, modelGuard);
1285
1253
  }
1286
- const storedThreadId = loadStoredThreadId(runtimeId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint);
1254
+ const storedThreadId = loadStoredThreadId(runtimeId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint, {
1255
+ ...(persistedMapping.status === 'new' || !persistedMapping.state.workspaceId
1256
+ ? {}
1257
+ : { workspaceId: persistedMapping.state.workspaceId }),
1258
+ allowLegacyPolicyMigration: persistedMapping.status !== 'new',
1259
+ });
1287
1260
  const effectiveModel = policy.model ?? codexDefaultModel;
1288
1261
  const initialEffortResolution = resolveCodexEffortForModel({
1289
1262
  models: codexModels,
1290
1263
  model: effectiveModel,
1291
- requestedEffort: config?.effort ?? configuredCodexEffort,
1264
+ requestedEffort: configuredCodexEffort,
1292
1265
  });
1293
1266
  const initialEffort = initialEffortResolution.value;
1294
1267
  const adapter = useAppServer
@@ -1336,6 +1309,7 @@ export async function main() {
1336
1309
  currentTurnOpenedAt: null,
1337
1310
  currentTurnUpdatedAt: null,
1338
1311
  currentTurnCanUseCodexAppTools: false,
1312
+ currentReplyAuthority: null,
1339
1313
  currentTurnAbortController: null,
1340
1314
  // Corrected by the first turn that runs; a session with no turn
1341
1315
  // publishes nothing anyway, and quiet is never an accident.
@@ -1353,6 +1327,10 @@ export async function main() {
1353
1327
  };
1354
1328
  sessions.set(conversationId, session);
1355
1329
  await controlPoller.baseline([conversationId]);
1330
+ await runtimeState.patchAgentSessionSnapshot(conversationId, {
1331
+ configurationStatus: 'ready',
1332
+ lastError: null,
1333
+ });
1356
1334
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
1357
1335
  writeState(session);
1358
1336
  writeTurn(session);
@@ -1384,6 +1362,7 @@ export async function main() {
1384
1362
  canUseCodexAppTools: turn.canUseCodexAppTools ?? false,
1385
1363
  ...(turn.turnVerbosity ? { turnVerbosity: turn.turnVerbosity } : {}),
1386
1364
  requestingUserId: turn.requestingUserId ?? null,
1365
+ replyAuthority: turn.replyAuthority ?? null,
1387
1366
  };
1388
1367
  if (toFront) {
1389
1368
  session.queue.unshift(nextPrompt);
@@ -1454,6 +1433,9 @@ export async function main() {
1454
1433
  if (!(session.adapter instanceof CodexAppServerAdapter)) {
1455
1434
  return deniedCodexAppToolResult('This Codex transport does not support dynamic tools.');
1456
1435
  }
1436
+ if (outboundPolicy !== 'open' && outboundPolicy !== 'approval-required') {
1437
+ return deniedCodexAppToolResult('Outbound communication is not enabled by the agent operator.');
1438
+ }
1457
1439
  return handleCodexCommunicateToolCall(client, params);
1458
1440
  }
1459
1441
  if (serviceAgentMode && !isCodexServiceAgentToolCall(params)) {
@@ -1588,6 +1570,8 @@ export async function main() {
1588
1570
  currentThreadId: session.adapter.getThreadId(),
1589
1571
  currentCwd: session.cwd,
1590
1572
  workspaces: workspaceOptions,
1573
+ communicationEnabled: outboundPolicy === 'open'
1574
+ || outboundPolicy === 'approval-required',
1591
1575
  model: session.state.model ?? null,
1592
1576
  effort: session.state.effort ?? null,
1593
1577
  }, params);
@@ -1878,16 +1862,22 @@ export async function main() {
1878
1862
  }
1879
1863
  catch (error) {
1880
1864
  const message = error instanceof Error ? error.message : String(error);
1881
- const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
1882
1865
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
1866
+ await runtimeState.patchAgentSessionSnapshot(input.conversationId, {
1867
+ configurationStatus: 'configuration_required',
1868
+ lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
1869
+ }).catch(() => { });
1883
1870
  await markQueuedMessageAccepted(input.conversationId, input.message.id, shouldMarkAccepted);
1884
- await sendMessageWithRetryChunked(client, input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
1871
+ await sendMessageWithRetryChunked(client, input.conversationId, LOCAL_CONFIGURATION_REQUIRED_MESSAGE, {
1885
1872
  messageId: `codex-start-failed-${input.message.id}`,
1886
1873
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
1887
1874
  metadata: {
1875
+ turnId: `codex-start:${input.message.id}`,
1876
+ runtimeStatus: 'configuration_required',
1888
1877
  turnSemantics: 'turn_complete',
1889
1878
  replyBehavior: 'suppress_auto_reply',
1890
1879
  },
1880
+ ...(input.replyAuthority ? { replyAuthority: input.replyAuthority } : {}),
1891
1881
  }).catch(() => { });
1892
1882
  recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1893
1883
  return;
@@ -1906,7 +1896,10 @@ export async function main() {
1906
1896
  ...(useAppServer ? { noReplyToolName: CODEX_NO_REPLY_MODEL_TOOL_NAME } : {}),
1907
1897
  });
1908
1898
  if (session.running && deliveryIntent === 'interrupt') {
1909
- enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message));
1899
+ enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, {
1900
+ ...resolveCodexTurnModes(participantContext, input.message),
1901
+ replyAuthority: input.replyAuthority ?? null,
1902
+ });
1910
1903
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
1911
1904
  session.currentTurnAbortController?.abort(new Error('Codex turn interrupted by a newer message'));
1912
1905
  await session.adapter.interrupt().catch(() => { });
@@ -1914,10 +1907,14 @@ export async function main() {
1914
1907
  typingSignals.clear(input.conversationId).catch(() => { });
1915
1908
  return;
1916
1909
  }
1917
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message));
1910
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, {
1911
+ ...resolveCodexTurnModes(participantContext, input.message),
1912
+ replyAuthority: input.replyAuthority ?? null,
1913
+ });
1918
1914
  }
1919
1915
  function sendTurnArtifactFile(session, file) {
1920
1916
  return sendMediaFileMessage(client, session.conversationId, file.path, '', {
1917
+ ...(session.currentReplyAuthority ? { replyAuthority: session.currentReplyAuthority } : {}),
1921
1918
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1922
1919
  metadata: {
1923
1920
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
@@ -1945,6 +1942,7 @@ export async function main() {
1945
1942
  session.currentTurnOpenedAt = Date.now();
1946
1943
  session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
1947
1944
  session.currentTurnCanUseCodexAppTools = nextTurn.canUseCodexAppTools === true;
1945
+ session.currentReplyAuthority = nextTurn.replyAuthority;
1948
1946
  session.currentTurnAbortController = new AbortController();
1949
1947
  // A continuation prompt (a plan-review result) carries none, and keeps the
1950
1948
  // conversation's last answer rather than silently reverting to verbose.
@@ -2249,6 +2247,9 @@ export async function main() {
2249
2247
  const turnTrail = buildFinalTurnTrail(session);
2250
2248
  await sendMessageWithRetryChunked(client, session.conversationId, result.finalMessage, {
2251
2249
  messageId: buildCodexMessageId(session, 'final'),
2250
+ ...(session.currentReplyAuthority
2251
+ ? { replyAuthority: session.currentReplyAuthority }
2252
+ : {}),
2252
2253
  ...(session.activeSelfContextId
2253
2254
  ? { selfContextId: session.activeSelfContextId }
2254
2255
  : {}),
@@ -2274,6 +2275,9 @@ export async function main() {
2274
2275
  const turnTrail = buildFinalTurnTrail(session);
2275
2276
  await sendMessageWithRetryChunked(client, session.conversationId, userVisibleError, {
2276
2277
  messageId: buildCodexMessageId(session, 'error'),
2278
+ ...(session.currentReplyAuthority
2279
+ ? { replyAuthority: session.currentReplyAuthority }
2280
+ : {}),
2277
2281
  ...(session.activeSelfContextId
2278
2282
  ? { selfContextId: session.activeSelfContextId }
2279
2283
  : {}),
@@ -2330,6 +2334,9 @@ export async function main() {
2330
2334
  await routeArtifactsOnce();
2331
2335
  await sendMessageWithRetryChunked(client, session.conversationId, message, {
2332
2336
  messageId: buildCodexMessageId(session, 'failure'),
2337
+ ...(session.currentReplyAuthority
2338
+ ? { replyAuthority: session.currentReplyAuthority }
2339
+ : {}),
2333
2340
  ...(session.activeSelfContextId
2334
2341
  ? { selfContextId: session.activeSelfContextId }
2335
2342
  : {}),
@@ -2358,6 +2365,7 @@ export async function main() {
2358
2365
  session.currentTurnOpenedAt = null;
2359
2366
  session.currentTurnUpdatedAt = null;
2360
2367
  session.currentTurnCanUseCodexAppTools = false;
2368
+ session.currentReplyAuthority = null;
2361
2369
  session.currentTurnSilenced = false;
2362
2370
  session.lastAcceptedIntent = null;
2363
2371
  session.resetRequested = false;
@@ -2486,64 +2494,6 @@ export async function main() {
2486
2494
  probe.close();
2487
2495
  }
2488
2496
  }
2489
- function applySessionControl(conversationId, control) {
2490
- const session = sessions.get(conversationId);
2491
- if (!session || session.closed)
2492
- return;
2493
- if (serviceAgentMode) {
2494
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring user session controls for service-agent runtime`);
2495
- writeState(session);
2496
- return;
2497
- }
2498
- let modelChanged = false;
2499
- if (control.model && control.model !== session.state.model) {
2500
- if (codexModelOptions.length > 0 && !codexModelOptions.some((option) => option.value === control.model)) {
2501
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring model outside the discovered Codex catalog (${control.model})`);
2502
- writeState(session);
2503
- return;
2504
- }
2505
- const modelGuard = buildCodexModelGuardMessage(control.model, codexCliStatus);
2506
- if (modelGuard) {
2507
- session.state.lastError = modelGuard;
2508
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
2509
- writeState(session);
2510
- // The poller consumes the node; skip effort handling for this pass,
2511
- // matching the legacy loop.
2512
- return;
2513
- }
2514
- session.adapter.setModel(control.model);
2515
- session.state.model = control.model;
2516
- modelChanged = true;
2517
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
2518
- }
2519
- if (control.permissionMode) {
2520
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
2521
- // Convergence contract: a consumed session control must always be
2522
- // answered. Re-publish the currently applied state so clients settle on
2523
- // the authoritative value instead of holding the composer until timeout.
2524
- }
2525
- if (control.effort || modelChanged) {
2526
- const effortResolution = resolveCodexEffortForModel({
2527
- models: codexModels,
2528
- model: session.state.model,
2529
- requestedEffort: control.effort ?? session.state.effort,
2530
- });
2531
- if (effortResolution.value !== session.state.effort) {
2532
- session.adapter.setReasoningEffort(effortResolution.value);
2533
- session.state.effort = effortResolution.value ?? undefined;
2534
- }
2535
- if (control.effort && !effortResolution.accepted) {
2536
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${control.effort} is unsupported by ${session.state.model ?? 'the active model'}; reset to ${effortResolution.value ?? 'the model default'}`);
2537
- }
2538
- else if (control.effort) {
2539
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Reasoning effort set for next turn -> ${control.effort}`);
2540
- }
2541
- else if (modelChanged && effortResolution.value) {
2542
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Reasoning effort for ${session.state.model} -> ${effortResolution.value}`);
2543
- }
2544
- }
2545
- writeState(session);
2546
- }
2547
2497
  async function handleControlSignal(event) {
2548
2498
  const { conversationId, type } = event;
2549
2499
  const session = sessions.get(conversationId);
@@ -2609,9 +2559,6 @@ export async function main() {
2609
2559
  conversationIds: () => sessions.keys(),
2610
2560
  hasActiveWork: () => [...sessions.values()].some((session) => !session.closed
2611
2561
  && (session.running || session.queue.length > 0 || session.turnState === 'waiting_input')),
2612
- onSessionControl: ({ conversationId, control }) => {
2613
- applySessionControl(conversationId, control);
2614
- },
2615
2562
  onSignal: handleControlSignal,
2616
2563
  onPrimitive: handleControlPrimitive,
2617
2564
  onError: (error) => {
@@ -2647,41 +2594,6 @@ export async function main() {
2647
2594
  agentId,
2648
2595
  rtdb,
2649
2596
  clientType: 'codex',
2650
- runtime: runtimeDescriptor,
2651
- workspaceOptions,
2652
- defaultCwd: workingDir,
2653
- extraSessionConfigFields: CODEX_SESSION_CONFIG_FIELDS,
2654
- liveSessionConfigByConversation: new Map(Array.from(serviceAgentMode ? knownConversationIds : sessions.keys()).map((conversationId) => {
2655
- const session = sessions.get(conversationId);
2656
- if (serviceAgentMode) {
2657
- return [
2658
- conversationId,
2659
- buildCodexServiceSnapshotConfig({
2660
- model: codexDefaultModel ?? session?.state.model ?? undefined,
2661
- permissionMode: codexPermissionEnvelope.defaultPermissionMode,
2662
- effort: codexDefaultEffort ?? session?.state.effort ?? undefined,
2663
- workspaceId: resolveWorkspaceIdForBaseCwd(workingDir) ?? undefined,
2664
- }),
2665
- ];
2666
- }
2667
- if (!session) {
2668
- // Non-service snapshots are published only for live sessions,
2669
- // but keep this branch total if that invariant changes.
2670
- return [conversationId, {}];
2671
- }
2672
- const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
2673
- return [
2674
- conversationId,
2675
- buildCodexLiveSessionConfig({
2676
- model: session.state.model,
2677
- permissionMode: session.state.permissionMode,
2678
- effort: session.state.effort,
2679
- workspaceId,
2680
- executionMode: session.environment.mode,
2681
- executionBranch: session.environment.branch ?? null,
2682
- }),
2683
- ];
2684
- })),
2685
2597
  }).catch((error) => {
2686
2598
  console.error('[canon-codex] Failed to publish session snapshots:', error);
2687
2599
  });
@@ -2865,6 +2777,7 @@ export async function main() {
2865
2777
  selfContexts: payload.selfContexts,
2866
2778
  provenance: payload.provenance,
2867
2779
  turnDispatch: payload.turnDispatch,
2780
+ replyAuthority: payload.replyAuthority,
2868
2781
  }).then(() => settleInboundMessageId(message.id, true), (error) => {
2869
2782
  settleInboundMessageId(message.id, false);
2870
2783
  console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Failed to queue inbound message:`, error instanceof Error ? error.message : error);
@@ -2876,6 +2789,12 @@ export async function main() {
2876
2789
  onConversationUpdated: (payload) => {
2877
2790
  handleConversationUpdated(payload);
2878
2791
  },
2792
+ onAgentContext: (context) => {
2793
+ ownerId = context.ownerId;
2794
+ ownerName = context.ownerName;
2795
+ outboundPolicy = context.outboundPolicy;
2796
+ codexDynamicTools = filterCodexCommunicationTools(baseCodexDynamicTools, outboundPolicy);
2797
+ },
2879
2798
  onConnected: () => {
2880
2799
  streamConnected = true;
2881
2800
  void publishRuntimeHeartbeat();
package/dist/register.js CHANGED
@@ -12,6 +12,7 @@ REQUIRED
12
12
 
13
13
  FLAGS
14
14
  --profile <name> Local profile name in ~/.canon/agents.json
15
+ --agent-id <id> Reconnect this exact transferred agent identity
15
16
  --environment <id> Canon environment ID (or CANON_ENVIRONMENT_ID; default: canon-prod-v1)
16
17
  --base-url <url> Canon API base URL override
17
18
  --stream-url <url> Canon stream URL override
@@ -23,6 +24,7 @@ FLAGS
23
24
  EXAMPLES
24
25
  canon-codex-register --name "My Codex" --description "Local coding agent" --phone "+15551234567"
25
26
  canon-codex-register --name "Frontend" --description "React work" --phone "+15551234567" --profile frontend
27
+ canon-codex-register --name "Sold Agent" --description "Existing agent" --phone "+15551234567" --agent-id <id>
26
28
 
27
29
  After approval, start it with CANON_AGENT=<profile> canon-codex --cwd /path/to/project.`;
28
30
  const OPTIONS = {
@@ -1,4 +1,5 @@
1
1
  import { type ExecutionEnvironmentMode } from '@canonmsg/core';
2
+ export declare const CODEX_LOCAL_POLICY_FINGERPRINT_FORMAT: "local-v1";
2
3
  export declare function buildCodexThreadPolicyFingerprint(input: {
3
4
  baseCwd: string;
4
5
  executionMode?: ExecutionEnvironmentMode;
@@ -10,6 +11,9 @@ export declare function buildCodexThreadPolicyFingerprint(input: {
10
11
  fullAuto?: boolean;
11
12
  bypassApprovalsAndSandbox?: boolean;
12
13
  }): string;
13
- export declare function loadStoredThreadId(runtimeId: string, conversationId: string, baseCwd: string, executionMode?: ExecutionEnvironmentMode, policyFingerprint?: string): string | null;
14
+ export declare function loadStoredThreadId(runtimeId: string, conversationId: string, baseCwd: string, executionMode?: ExecutionEnvironmentMode, policyFingerprint?: string, options?: {
15
+ workspaceId?: string;
16
+ allowLegacyPolicyMigration?: boolean;
17
+ }): string | null;
14
18
  export declare function saveStoredThreadId(runtimeId: string, conversationId: string, baseCwd: string, threadId: string, executionMode?: ExecutionEnvironmentMode, policyFingerprint?: string): void;
15
19
  export declare function clearStoredThreadId(runtimeId: string, conversationId: string, baseCwd?: string, executionMode?: ExecutionEnvironmentMode): void;
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { clearRuntimeSessionState, loadRuntimeSessionState, saveRuntimeSessionState, } from '@canonmsg/core';
3
+ export const CODEX_LOCAL_POLICY_FINGERPRINT_FORMAT = 'local-v1';
3
4
  export function buildCodexThreadPolicyFingerprint(input) {
4
5
  return createHash('sha256').update(JSON.stringify({
5
6
  version: 2,
@@ -20,18 +21,24 @@ export function buildCodexThreadPolicyFingerprint(input) {
20
21
  bypassApprovalsAndSandbox: input.bypassApprovalsAndSandbox === true,
21
22
  })).digest('hex').slice(0, 24);
22
23
  }
23
- export function loadStoredThreadId(runtimeId, conversationId, baseCwd, executionMode, policyFingerprint) {
24
+ export function loadStoredThreadId(runtimeId, conversationId, baseCwd, executionMode, policyFingerprint, options = {}) {
24
25
  const state = loadRuntimeSessionState(runtimeId, {
25
26
  conversationId,
26
27
  baseCwd,
27
28
  executionMode,
29
+ workspaceId: options.workspaceId,
28
30
  });
29
31
  if (state?.threadId) {
30
32
  if (policyFingerprint && state.codexPolicyFingerprint !== policyFingerprint) {
33
+ if (options.allowLegacyPolicyMigration === true
34
+ && state.codexPolicyFingerprintFormat === undefined) {
35
+ return state.threadId;
36
+ }
31
37
  clearRuntimeSessionState(runtimeId, {
32
38
  conversationId,
33
39
  baseCwd,
34
40
  executionMode,
41
+ workspaceId: options.workspaceId,
35
42
  });
36
43
  return null;
37
44
  }
@@ -45,7 +52,12 @@ export function saveStoredThreadId(runtimeId, conversationId, baseCwd, threadId,
45
52
  baseCwd,
46
53
  executionMode,
47
54
  threadId,
48
- ...(policyFingerprint ? { codexPolicyFingerprint: policyFingerprint } : {}),
55
+ ...(policyFingerprint
56
+ ? {
57
+ codexPolicyFingerprint: policyFingerprint,
58
+ codexPolicyFingerprintFormat: CODEX_LOCAL_POLICY_FINGERPRINT_FORMAT,
59
+ }
60
+ : {}),
49
61
  });
50
62
  }
51
63
  export function clearStoredThreadId(runtimeId, conversationId, baseCwd, executionMode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,11 +29,11 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@canonmsg/agent-sdk": "^9.0.0",
33
- "@canonmsg/agent-tools": "^0.7.0",
32
+ "@canonmsg/agent-sdk": "^10.0.0",
33
+ "@canonmsg/agent-tools": "^0.8.0",
34
34
  "@canonmsg/coding-agent-host": "^0.7.0",
35
- "@canonmsg/core": "^11.0.0",
36
- "@canonmsg/rich-cards": "^0.10.3"
35
+ "@canonmsg/core": "^12.0.0",
36
+ "@canonmsg/rich-cards": "^0.10.4"
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=18.0.0"