@canonmsg/codex-plugin 0.9.0 → 0.9.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
@@ -22,7 +22,15 @@ canon-codex --cwd /path/to/project
22
22
 
23
23
  Registration saves a Canon profile in `~/.canon/agents.json`, the same shared profile store used by the Claude Code integration and supported by the OpenClaw plugin.
24
24
 
25
- If the terminal closes or the machine restarts, the agent goes offline until you start the host again. To bring back the same registered agent, rerun `canon-codex --cwd /path/to/project`. Do not run registration again unless Canon tells you the saved API key is invalid. If you registered multiple profiles, relaunch the same one with `CANON_AGENT=<profile> canon-codex --cwd /path/to/project`.
25
+ If the terminal closes or the machine restarts, the agent goes offline until you start the host again. Install `@canonmsg/local-agents` and run `canon-necromance` to list every recorded local agent from newest to oldest, then revive one in the foreground:
26
+
27
+ ```bash
28
+ npm install -g @canonmsg/local-agents
29
+ canon-necromance
30
+ canon-necromance revive my-codex
31
+ ```
32
+
33
+ Do not run registration again unless Canon tells you the saved API key is invalid. If you registered multiple profiles, relaunch the same one with `CANON_AGENT=<profile> canon-codex --cwd /path/to/project`. Keep the revived terminal open; closing it takes this local agent offline.
26
34
 
27
35
  You do not need a git repo for host mode. The plugin passes `--skip-git-repo-check` to Codex, so any readable working directory is valid.
28
36
 
@@ -99,7 +107,7 @@ If `canon-codex` starts but cannot find the `codex` binary, either fix your `PAT
99
107
  canon-codex --cwd /path/to/project --codex-bin /absolute/path/to/codex
100
108
  ```
101
109
 
102
- If Canon rejects authenticated requests with `401 Invalid API key`, the stored Canon profile needs a fresh key. Rerun registration for the same profile to overwrite `~/.canon/agents.json`, then restart the host:
110
+ If Canon rejects authenticated requests with `401 Invalid API key`, the stored Canon profile needs a fresh key. Reconnect the same profile to overwrite `~/.canon/agents.json`, then restart or revive the host:
103
111
 
104
112
  ```bash
105
113
  canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567" --profile my-codex
package/dist/host.js CHANGED
@@ -3,8 +3,7 @@ import { setDefaultResultOrder } from 'node:dns';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { getCodexImagePath, materializeMessageMedia, } from '@canonmsg/agent-sdk';
6
- import { buildConfiguredWorkspaceOptionsWithRoots, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, clearSessionState, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfile, initRTDBAuth, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, releaseLock, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, writeRuntimeInfo, shouldTriggerAgentTurn, writeSessionState, writeTurnState, } from '@canonmsg/core';
7
- import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
6
+ import { buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, shouldTriggerAgentTurn, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
8
7
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
9
8
  import { CodexConversationAdapter, } from './adapter.js';
10
9
  import { clearStoredThreadId, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
@@ -26,63 +25,15 @@ let workspaceOptions = [];
26
25
  let workspaceRoots = [];
27
26
  let workspaceRootMetadata = [];
28
27
  function buildCodexRuntimeDescriptor(input) {
29
- return {
30
- coreControls: [
31
- {
32
- id: 'model',
33
- label: 'Model',
34
- options: input.models,
35
- defaultValue: input.models[0]?.value ?? null,
36
- availability: 'setup_and_live',
37
- liveBehavior: 'next_turn',
38
- selectionPolicy: 'inherit',
39
- },
40
- {
41
- id: 'workspace',
42
- label: 'Project',
43
- options: input.workspaces.map((workspace) => ({
44
- value: workspace.id,
45
- label: workspace.label,
46
- ...(workspace.description ? { description: workspace.description } : {}),
47
- ...(workspace.workspaceRootId ? { workspaceRootId: workspace.workspaceRootId } : {}),
48
- ...(workspace.workspaceRelativePath ? { workspaceRelativePath: workspace.workspaceRelativePath } : {}),
49
- ...(workspace.source ? { source: workspace.source } : {}),
50
- })),
51
- defaultValue: input.workspaces[0]?.id ?? null,
52
- availability: 'setup',
53
- liveBehavior: 'none',
54
- selectionPolicy: 'inherit',
55
- description: input.workspaceRoots?.length
56
- ? 'Choose one of the projects discovered inside the approved local roots for this host.'
57
- : 'Choose one of the local projects advertised by this host.',
58
- },
59
- {
60
- id: 'executionMode',
61
- label: 'Execution mode',
62
- options: input.executionModes.map((mode) => ({
63
- value: mode,
64
- label: mode === 'worktree' ? 'Isolated worktree' : 'Use shared project',
65
- description: mode === 'worktree'
66
- ? 'Creates or reuses a per-conversation git worktree under ~/.canon/conversation-worktrees when the selected project is a git repo.'
67
- : 'Runs directly in the selected project folder. Changes happen there.',
68
- })),
69
- defaultValue: null,
70
- availability: 'setup',
71
- liveBehavior: 'none',
72
- selectionPolicy: 'required_explicit',
73
- },
74
- ],
75
- runtimeControls: [
76
- {
77
- id: 'permissionMode',
78
- label: 'Execution policy',
79
- options: input.permissionModes,
80
- defaultValue: input.defaultPermissionMode ?? null,
81
- availability: 'setup',
82
- liveBehavior: 'none',
83
- selectionPolicy: 'inherit',
84
- },
85
- ],
28
+ return buildFirstPartyCodingRuntimeDescriptor({
29
+ clientType: 'codex',
30
+ models: input.models,
31
+ workspaces: input.workspaces,
32
+ workspaceRoots: input.workspaceRoots,
33
+ executionModes: input.executionModes,
34
+ permissionModes: input.permissionModes,
35
+ defaultPermissionMode: input.defaultPermissionMode,
36
+ streamingTextMode: 'snapshot',
86
37
  actions: [
87
38
  {
88
39
  id: 'stop',
@@ -105,10 +56,7 @@ function buildCodexRuntimeDescriptor(input) {
105
56
  dispatch: { kind: 'signal', signal: 'stop_and_drop' },
106
57
  },
107
58
  ],
108
- workspaceRoots: input.workspaceRoots,
109
- supportsInterrupt: true,
110
- streamingTextMode: 'snapshot',
111
- };
59
+ });
112
60
  }
113
61
  function normalizeRuntimeTurnState(value) {
114
62
  const normalizedTurn = normalizeTurnState(value);
@@ -218,9 +166,9 @@ export async function main() {
218
166
  if (typeof args['ask-for-approval'] === 'string') {
219
167
  console.error('[canon-codex] Note: newer Codex CLI releases do not accept --ask-for-approval for `codex exec`; Canon will translate compatible legacy usage when possible.');
220
168
  }
221
- const { apiKey, agentId: profileAgentId, profile } = resolveCanonAgent({ logPrefix: '[canon-codex]' });
169
+ const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
222
170
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
223
- const client = new CanonClient(apiKey);
171
+ const client = new CanonClient(apiKey, baseUrl);
224
172
  initRTDBAuth(client);
225
173
  let agentId;
226
174
  let ownerId = null;
@@ -240,6 +188,39 @@ export async function main() {
240
188
  }
241
189
  console.error(`[canon-codex] Authenticated as ${agentId}`);
242
190
  }
191
+ const launchArgs = [...process.argv.slice(2)];
192
+ if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
193
+ launchArgs.push('--cwd', workingDir);
194
+ }
195
+ const runtimeId = buildLocalRuntimeId({
196
+ runtime: 'codex',
197
+ profile,
198
+ cwd: workingDir,
199
+ launchCommand: ['canon-codex', ...launchArgs],
200
+ });
201
+ upsertLocalRuntimeEntry({
202
+ id: runtimeId,
203
+ runtime: 'codex',
204
+ profile,
205
+ agentId,
206
+ agentName: profileAgentName,
207
+ cwd: workingDir,
208
+ baseCwd: workingDir,
209
+ workspaceRoots: workspaceRoots.map((root) => root.cwd),
210
+ workspaces: workspaceOptions.map((workspace) => workspace.cwd),
211
+ launchCommand: ['canon-codex', ...launchArgs],
212
+ pid: process.pid,
213
+ status: profile ? 'running' : 'manual',
214
+ reviveCapability: profile ? 'revivable' : 'manual',
215
+ surfaceMode: 'host',
216
+ lastStartedAt: new Date().toISOString(),
217
+ lastHeartbeatAt: new Date().toISOString(),
218
+ });
219
+ const runtimeState = createRuntimeStatePublisher({
220
+ agentId,
221
+ clientType: 'codex',
222
+ hostMode: true,
223
+ });
243
224
  const sessions = new Map();
244
225
  const pendingSessionCreations = new Map();
245
226
  const conversationCache = new Map();
@@ -293,7 +274,7 @@ export async function main() {
293
274
  });
294
275
  }
295
276
  function writeState(session) {
296
- writeSessionState(session.conversationId, agentId, {
277
+ runtimeState.writeSessionState(session.conversationId, {
297
278
  lastError: session.state.lastError,
298
279
  model: session.state.model,
299
280
  cwd: session.cwd,
@@ -310,7 +291,7 @@ export async function main() {
310
291
  }).catch(() => { });
311
292
  }
312
293
  function writeTurn(session) {
313
- writeTurnState(session.conversationId, agentId, {
294
+ runtimeState.writeTurnState(session.conversationId, {
314
295
  turnId: session.currentTurnId,
315
296
  state: session.turnState,
316
297
  queueDepth: session.queue.length,
@@ -329,7 +310,7 @@ export async function main() {
329
310
  await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
330
311
  }
331
312
  function clearStreaming(conversationId) {
332
- rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
313
+ runtimeState.clearStreaming(conversationId).catch(() => { });
333
314
  }
334
315
  async function handoffFinalMessage(conversationId) {
335
316
  await sleep(FINAL_MESSAGE_HANDOFF_MS);
@@ -365,8 +346,8 @@ export async function main() {
365
346
  stopVisibleWorkSignal(session);
366
347
  releaseConversationEnvironment(session.environment);
367
348
  clearStreaming(conversationId);
368
- clearSessionState(conversationId, agentId).catch(() => { });
369
- clearTurnState(conversationId, agentId).catch(() => { });
349
+ runtimeState.clearSessionState(conversationId).catch(() => { });
350
+ runtimeState.clearTurnState(conversationId).catch(() => { });
370
351
  client.setTyping(conversationId, false).catch(() => { });
371
352
  sessions.delete(conversationId);
372
353
  }
@@ -409,7 +390,7 @@ export async function main() {
409
390
  try {
410
391
  const sessionCwd = environment.cwd;
411
392
  const sessionModel = config?.model ?? (typeof args.model === 'string' ? args.model : undefined);
412
- const storedThreadId = loadStoredThreadId(agentId, conversationId, sessionCwd);
393
+ const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode);
413
394
  if (config?.permissionMode
414
395
  && !codexPermissionEnvelope.availablePermissionModes.some((option) => option.value === config.permissionMode)) {
415
396
  throw new ExecutionEnvironmentError(`Permission mode "${config.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.');
@@ -577,17 +558,16 @@ export async function main() {
577
558
  writeState(session);
578
559
  writeTurn(session);
579
560
  startVisibleWorkSignal(session);
580
- rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
561
+ runtimeState.writeStreaming(session.conversationId, {
581
562
  text: 'Thinking…',
582
563
  status: 'thinking',
583
- updatedAt: { '.sv': 'timestamp' },
584
564
  }).catch(() => { });
585
565
  try {
586
566
  const turnImagePaths = nextTurn.imagePaths ?? [];
587
567
  const result = await session.adapter.runTurn(nextTurn.prompt, (event) => {
588
568
  session.lastActivity = Date.now();
589
569
  if (event.type === 'thread.started') {
590
- saveStoredThreadId(agentId, session.conversationId, session.cwd, event.threadId);
570
+ saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, event.threadId, session.environment.mode);
591
571
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Thread ${event.threadId}`);
592
572
  return;
593
573
  }
@@ -596,10 +576,9 @@ export async function main() {
596
576
  writeTurn(session);
597
577
  stopVisibleWorkSignal(session);
598
578
  client.setTyping(session.conversationId, false).catch(() => { });
599
- rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
579
+ runtimeState.writeStreaming(session.conversationId, {
600
580
  text: event.text,
601
581
  status: 'streaming',
602
- updatedAt: { '.sv': 'timestamp' },
603
582
  }).catch(() => { });
604
583
  return;
605
584
  }
@@ -607,10 +586,9 @@ export async function main() {
607
586
  session.turnState = 'tool';
608
587
  writeTurn(session);
609
588
  startVisibleWorkSignal(session);
610
- rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
589
+ runtimeState.writeStreaming(session.conversationId, {
611
590
  text: summarizeCommand(event.command),
612
591
  status: 'tool',
613
- updatedAt: { '.sv': 'timestamp' },
614
592
  }).catch(() => { });
615
593
  return;
616
594
  }
@@ -621,7 +599,7 @@ export async function main() {
621
599
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`);
622
600
  }, turnImagePaths);
623
601
  if (result.threadId) {
624
- saveStoredThreadId(agentId, session.conversationId, session.cwd, result.threadId);
602
+ saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode);
625
603
  }
626
604
  if (!result.interrupted && result.finalMessage) {
627
605
  await client.sendMessage(session.conversationId, result.finalMessage, {
@@ -677,7 +655,9 @@ export async function main() {
677
655
  },
678
656
  }).catch(() => { });
679
657
  await handoffFinalMessage(session.conversationId);
680
- clearStoredThreadId(agentId, session.conversationId);
658
+ if (error instanceof Error && /invalid|not found|unknown thread/i.test(error.message)) {
659
+ clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
660
+ }
681
661
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
682
662
  }
683
663
  finally {
@@ -721,6 +701,12 @@ export async function main() {
721
701
  }),
722
702
  };
723
703
  const publishRuntimeHeartbeat = async () => {
704
+ heartbeatLocalRuntimeEntry(runtimeId, {
705
+ agentId,
706
+ agentName: profileAgentName,
707
+ cwd: workingDir,
708
+ baseCwd: workingDir,
709
+ });
724
710
  if (!streamConnected)
725
711
  return;
726
712
  await refreshKnownConversationIds().catch((error) => {
@@ -736,6 +722,7 @@ export async function main() {
736
722
  runtime: runtimeDescriptor,
737
723
  workspaceOptions,
738
724
  defaultCwd: workingDir,
725
+ extraSessionConfigFields: ['permissionMode'],
739
726
  liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
740
727
  const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
741
728
  return [
@@ -800,7 +787,7 @@ export async function main() {
800
787
  'Codex review, compact/rollback, live plan/diff/reasoning updates, PTY command execution, plugin/app/MCP inventory, and structured approvals require the future app-server transport.',
801
788
  ],
802
789
  };
803
- await writeRuntimeInfo(conversationId, agentId, payload);
790
+ await runtimeState.writeRuntimeInfo(conversationId, payload);
804
791
  })).catch((error) => {
805
792
  console.error('[canon-codex] Failed to publish runtime info:', error);
806
793
  });
@@ -821,6 +808,13 @@ export async function main() {
821
808
  behavior: payload.behavior,
822
809
  workSessions: payload.workSessions,
823
810
  });
811
+ if (message.id) {
812
+ saveRuntimeSessionState(runtimeId, {
813
+ conversationId: payload.conversationId,
814
+ baseCwd: workingDir,
815
+ lastInboundMessageId: message.id,
816
+ });
817
+ }
824
818
  },
825
819
  onConnected: () => {
826
820
  streamConnected = true;
@@ -829,7 +823,7 @@ export async function main() {
829
823
  },
830
824
  onDisconnected: () => {
831
825
  streamConnected = false;
832
- rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
826
+ runtimeState.clearAgentRuntime().catch(() => { });
833
827
  console.error('[canon-codex] SSE disconnected');
834
828
  },
835
829
  onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
@@ -881,38 +875,55 @@ export async function main() {
881
875
  knownConversationIds.add(conversation.id);
882
876
  conversationCache.set(conversation.id, conversation);
883
877
  clearStreaming(conversation.id);
884
- clearSessionState(conversation.id, agentId).catch(() => { });
885
- clearTurnState(conversation.id, agentId).catch(() => { });
878
+ runtimeState.clearSessionState(conversation.id).catch(() => { });
879
+ runtimeState.clearTurnState(conversation.id).catch(() => { });
886
880
  }
887
881
  for (const conversation of conversations) {
888
- if (!conversation.lastMessage || conversation.lastMessage.senderId === agentId)
889
- continue;
890
- const latestPage = await client.getMessagesPage(conversation.id, 1);
891
- const latestMessage = latestPage.messages[0];
892
- if (!latestMessage || latestMessage.senderId === agentId)
893
- continue;
894
- const senderTurnState = latestMessage.senderType === 'ai_agent'
895
- ? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
896
- : null;
897
- const triggerDecision = shouldTriggerAgentTurn({
898
- senderType: latestMessage.senderType ?? 'human',
899
- metadata: latestMessage.metadata,
900
- senderTurnState,
901
- });
902
- if (!triggerDecision.allow) {
903
- console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);
904
- continue;
905
- }
906
- console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovering latest inbound message on startup`);
907
- await enqueueInboundMessage({
882
+ const cursor = loadRuntimeSessionState(runtimeId, {
908
883
  conversationId: conversation.id,
909
- message: latestMessage,
910
- senderName: latestMessage.senderId,
911
- isOwner: ownerId != null && latestMessage.senderId === ownerId,
912
- behavior: latestPage.behavior,
913
- workSessions: latestPage.workSessions,
914
- hydratedPage: latestPage,
915
- });
884
+ baseCwd: workingDir,
885
+ })?.lastInboundMessageId;
886
+ const latestPage = await client.getMessagesPage(conversation.id, 25);
887
+ const inboundMessages = latestPage.messages
888
+ .filter((message) => message.senderId !== agentId)
889
+ .sort((a, b) => String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')));
890
+ const cursorIndex = cursor
891
+ ? inboundMessages.findIndex((message) => message.id === cursor)
892
+ : -1;
893
+ const messagesToRecover = cursorIndex >= 0
894
+ ? inboundMessages.slice(cursorIndex + 1)
895
+ : inboundMessages.slice(-1);
896
+ for (const latestMessage of messagesToRecover) {
897
+ const senderTurnState = latestMessage.senderType === 'ai_agent'
898
+ ? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
899
+ : null;
900
+ const triggerDecision = shouldTriggerAgentTurn({
901
+ senderType: latestMessage.senderType ?? 'human',
902
+ metadata: latestMessage.metadata,
903
+ senderTurnState,
904
+ });
905
+ if (!triggerDecision.allow) {
906
+ console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);
907
+ continue;
908
+ }
909
+ console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovering inbound message on startup`);
910
+ await enqueueInboundMessage({
911
+ conversationId: conversation.id,
912
+ message: latestMessage,
913
+ senderName: latestMessage.senderId,
914
+ isOwner: ownerId != null && latestMessage.senderId === ownerId,
915
+ behavior: latestPage.behavior,
916
+ workSessions: latestPage.workSessions,
917
+ hydratedPage: latestPage,
918
+ });
919
+ if (latestMessage.id) {
920
+ saveRuntimeSessionState(runtimeId, {
921
+ conversationId: conversation.id,
922
+ baseCwd: workingDir,
923
+ lastInboundMessageId: latestMessage.id,
924
+ });
925
+ }
926
+ }
916
927
  }
917
928
  }
918
929
  catch (error) {
@@ -1006,25 +1017,23 @@ export async function main() {
1006
1017
  clearInterval(heartbeat);
1007
1018
  clearInterval(idleCheck);
1008
1019
  stream.stop();
1009
- await rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
1020
+ await runtimeState.clearAgentRuntime().catch(() => { });
1010
1021
  for (const session of [...sessions.values()]) {
1011
1022
  await session.adapter.interrupt().catch(() => { });
1012
1023
  closeSession(session.conversationId);
1013
1024
  }
1014
- const activeProfile = getActiveProfile();
1015
- if (activeProfile)
1016
- releaseLock(activeProfile);
1025
+ markLocalRuntimeStopped(runtimeId);
1026
+ (lockHandle ?? getActiveProfileLock())?.release();
1017
1027
  process.exit(0);
1018
1028
  };
1019
1029
  process.on('SIGINT', shutdown);
1020
1030
  process.on('SIGTERM', shutdown);
1031
+ process.on('SIGHUP', shutdown);
1021
1032
  console.error('[canon-codex] Ready — sessions created on demand');
1022
1033
  await new Promise(() => { });
1023
1034
  }
1024
1035
  runCli(import.meta.url, main, (error) => {
1025
1036
  console.error('[canon-codex] Fatal error:', error);
1026
- const activeProfile = getActiveProfile();
1027
- if (activeProfile)
1028
- releaseLock(activeProfile);
1037
+ getActiveProfileLock()?.release();
1029
1038
  process.exit(1);
1030
1039
  });
package/dist/register.js CHANGED
@@ -1,13 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { setDefaultResultOrder } from 'node:dns';
3
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
- import { join } from 'node:path';
3
+ import { readFileSync } from 'node:fs';
6
4
  import { parseArgs } from 'node:util';
7
- import { registerAndWaitForApproval } from '@canonmsg/core';
5
+ import { ackRegistrationApproval, clearPendingRegistration, getOrCreatePendingRegistration, registerAndWaitForApproval, updatePendingRegistration, upsertAgentProfile, AGENTS_PATH, } from '@canonmsg/core';
8
6
  import { runCli } from './cli-entry.js';
9
- const CANON_DIR = join(homedir(), '.canon');
10
- const AGENTS_PATH = join(CANON_DIR, 'agents.json');
11
7
  export async function main() {
12
8
  setDefaultResultOrder('ipv4first');
13
9
  const { values } = parseArgs({
@@ -34,6 +30,7 @@ export async function main() {
34
30
  // No existing profile state.
35
31
  }
36
32
  console.log(`Registering Codex agent "${values.name}" (profile: ${profileName})...`);
33
+ const pending = getOrCreatePendingRegistration(profileName, 'codex');
37
34
  const result = await registerAndWaitForApproval({
38
35
  name: values.name,
39
36
  description: values.description,
@@ -42,8 +39,14 @@ export async function main() {
42
39
  clientType: 'codex',
43
40
  baseUrl: values['base-url'],
44
41
  requestedAgentId: existingAgentId,
42
+ localRegistrationId: pending.localRegistrationId,
45
43
  }, {
46
- onSubmitted: (requestId) => {
44
+ onSubmitted: (requestId, pollToken) => {
45
+ updatePendingRegistration(profileName, {
46
+ requestId,
47
+ pollToken,
48
+ clientType: 'codex',
49
+ });
47
50
  console.log(`Registration submitted (request ID: ${requestId}).`);
48
51
  console.log('Waiting for approval in Canon app...');
49
52
  },
@@ -54,24 +57,26 @@ export async function main() {
54
57
  console.log('');
55
58
  switch (result.status) {
56
59
  case 'approved': {
57
- mkdirSync(CANON_DIR, { recursive: true });
58
- let profiles = {};
59
- try {
60
- profiles = JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
60
+ if (!result.apiKey || !result.agentId || !result.agentName) {
61
+ console.error('Approval completed but Canon did not return a usable API key. Run this command again to resume key pickup.');
62
+ process.exit(1);
61
63
  }
62
- catch {
63
- // File does not exist yet.
64
- }
65
- profiles[profileName] = {
64
+ upsertAgentProfile(profileName, {
66
65
  apiKey: result.apiKey,
67
66
  agentId: result.agentId,
68
67
  agentName: result.agentName,
69
68
  registeredAt: new Date().toISOString(),
70
- };
71
- writeFileSync(AGENTS_PATH, JSON.stringify(profiles, null, 2));
69
+ clientType: 'codex',
70
+ ...(typeof values['base-url'] === 'string' ? { baseUrl: values['base-url'] } : {}),
71
+ });
72
+ if (result.requestId) {
73
+ await ackRegistrationApproval(values['base-url'], result.requestId, result.pollToken);
74
+ }
75
+ clearPendingRegistration(profileName);
72
76
  console.log(`Approved! Agent: ${result.agentName} (${result.agentId})`);
73
77
  console.log(`Saved as profile "${profileName}" in ~/.canon/agents.json`);
74
- console.log('Start it with: canon-codex --cwd /path/to/project');
78
+ console.log('Start it with: CANON_AGENT=' + profileName + ' canon-codex --cwd /path/to/project');
79
+ console.log('Keep that terminal open. Closing it takes this local agent offline.');
75
80
  break;
76
81
  }
77
82
  case 'rejected':
@@ -1,3 +1,4 @@
1
- export declare function loadStoredThreadId(agentId: string, conversationId: string, cwd: string): string | null;
2
- export declare function saveStoredThreadId(agentId: string, conversationId: string, cwd: string, threadId: string): void;
3
- export declare function clearStoredThreadId(agentId: string, conversationId: string): void;
1
+ import { type ExecutionEnvironmentMode } from '@canonmsg/core';
2
+ export declare function loadStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd: string, executionMode?: ExecutionEnvironmentMode): string | null;
3
+ export declare function saveStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd: string, threadId: string, executionMode?: ExecutionEnvironmentMode): void;
4
+ export declare function clearStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd?: string, executionMode?: ExecutionEnvironmentMode): void;
@@ -1,6 +1,6 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { CANON_DIR } from '@canonmsg/core';
3
+ import { CANON_DIR, clearRuntimeSessionState, loadRuntimeSessionState, saveRuntimeSessionState, } from '@canonmsg/core';
4
4
  const STORE_PATH = join(CANON_DIR, 'codex-sessions.json');
5
5
  function loadStore() {
6
6
  try {
@@ -14,24 +14,50 @@ function saveStore(store) {
14
14
  mkdirSync(CANON_DIR, { recursive: true });
15
15
  writeFileSync(STORE_PATH, JSON.stringify(store, null, 2));
16
16
  }
17
- export function loadStoredThreadId(agentId, conversationId, cwd) {
17
+ export function loadStoredThreadId(runtimeId, agentId, conversationId, baseCwd, executionMode) {
18
+ if (runtimeId) {
19
+ const state = loadRuntimeSessionState(runtimeId, {
20
+ conversationId,
21
+ baseCwd,
22
+ executionMode,
23
+ });
24
+ if (state?.threadId)
25
+ return state.threadId;
26
+ }
18
27
  const store = loadStore();
19
28
  const record = store.agents[agentId]?.[conversationId];
20
- if (!record || record.cwd !== cwd)
29
+ if (!record || record.cwd !== baseCwd)
21
30
  return null;
22
31
  return record.threadId;
23
32
  }
24
- export function saveStoredThreadId(agentId, conversationId, cwd, threadId) {
33
+ export function saveStoredThreadId(runtimeId, agentId, conversationId, baseCwd, threadId, executionMode) {
34
+ if (runtimeId) {
35
+ saveRuntimeSessionState(runtimeId, {
36
+ conversationId,
37
+ baseCwd,
38
+ executionMode,
39
+ threadId,
40
+ });
41
+ return;
42
+ }
25
43
  const store = loadStore();
26
44
  store.agents[agentId] ??= {};
27
45
  store.agents[agentId][conversationId] = {
28
46
  threadId,
29
- cwd,
47
+ cwd: baseCwd,
30
48
  updatedAt: new Date().toISOString(),
31
49
  };
32
50
  saveStore(store);
33
51
  }
34
- export function clearStoredThreadId(agentId, conversationId) {
52
+ export function clearStoredThreadId(runtimeId, agentId, conversationId, baseCwd, executionMode) {
53
+ if (runtimeId) {
54
+ clearRuntimeSessionState(runtimeId, {
55
+ conversationId,
56
+ baseCwd,
57
+ executionMode,
58
+ });
59
+ return;
60
+ }
35
61
  const store = loadStore();
36
62
  if (!store.agents[agentId]?.[conversationId])
37
63
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,8 +29,8 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@canonmsg/agent-sdk": "^0.9.2",
33
- "@canonmsg/core": "^0.10.0"
32
+ "@canonmsg/agent-sdk": "^0.10.2",
33
+ "@canonmsg/core": "^0.14.0"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=18.0.0"
@@ -1,133 +0,0 @@
1
- /**
2
- * Host-runtime helpers, inlined from @canonmsg/core.
3
- *
4
- * These helpers glue Canon host wrappers (Codex, Claude Code) to the Canon
5
- * RTDB session/runtime surface. They used to live in
6
- * `@canonmsg/core/src/host-runtime/` but were moved into each plugin in
7
- * media-parity PR C so core does not have to carry host-specific concerns.
8
- *
9
- * Keep this file in lockstep with the equivalent file in
10
- * `packages/claude-code-plugin/src/host-runtime.ts`. If you change the
11
- * behavior here, update that copy too and adjust the shared golden
12
- * fixture test (`packages/codex-plugin/src/host-runtime.test.ts`).
13
- */
14
- import { type AgentClientType, type AgentRuntime, type CanonClient, type CanonConversation, type CanonMessage, type CanonMessagesPage, type MessageCreatedPayload, type ResolvedAgentBehaviorPolicy, type SessionWorkspaceConfig } from '@canonmsg/core';
15
- export interface HostInboundParticipantContext {
16
- conversationType: CanonConversation['type'] | 'unknown';
17
- memberCount: number | null;
18
- senderType: 'human' | 'ai_agent';
19
- senderName: string;
20
- isOwner: boolean;
21
- mentionedAgent: boolean;
22
- recentSenderTypes: Array<'human' | 'ai_agent'>;
23
- recentHumanCount: number;
24
- recentAgentCount: number;
25
- consecutiveAgentTurns: number;
26
- currentAgentStreakStartedByHuman: boolean;
27
- }
28
- type HostInboundMessage = {
29
- text?: string | null;
30
- contentType?: CanonMessage['contentType'] | null;
31
- attachments?: CanonMessage['attachments'];
32
- senderType?: CanonMessage['senderType'];
33
- mentions?: string[] | null;
34
- contactCard?: CanonMessage['contactCard'];
35
- };
36
- interface HostWorkspaceResolverOption {
37
- id: string;
38
- cwd: string;
39
- }
40
- export declare const HOST_ADMISSION_ACTION_CAPABILITIES: Readonly<{
41
- canStartDirectConversation: false;
42
- canSendContactRequest: false;
43
- canApprovePendingContactRequests: false;
44
- canRejectPendingContactRequests: false;
45
- }>;
46
- export declare function buildCanonHostPrompt(input: {
47
- hostLabel: string;
48
- content: string;
49
- conversationId: string;
50
- participantContext: HostInboundParticipantContext;
51
- behavior?: ResolvedAgentBehaviorPolicy | null;
52
- workSession?: MessageCreatedPayload['message']['workSession'];
53
- workSessions?: MessageCreatedPayload['workSessions'];
54
- buildInboundContextLines: (context: HostInboundParticipantContext) => string[];
55
- }): string;
56
- /**
57
- * Render the **text portion** of an inbound Canon message. Images are
58
- * referenced by short placeholders — their actual bytes are delivered to the
59
- * host as native vision/media inputs (Codex `-i <file>`, Anthropic image
60
- * blocks). URLs are intentionally *not* inlined, since the harness never
61
- * needs to refetch and earlier `[Image: <url>]` inlining caused vision
62
- * models to see a string about an image instead of the image itself.
63
- *
64
- * `materialized` may be passed so non-image attachments can reference a
65
- * local path the agent can Read. Without it we fall back to an unadorned
66
- * placeholder; the vision path still works because image args carry the
67
- * file path directly.
68
- */
69
- export declare function renderCanonHostInboundContent(message: HostInboundMessage, materialized?: ReadonlyArray<{
70
- kind: 'image' | 'audio' | 'file';
71
- path: string;
72
- fileName?: string;
73
- durationMs?: number;
74
- index: number;
75
- }>): string;
76
- export declare function buildHydratedInboundContext(input: {
77
- agentId: string;
78
- conversation: CanonConversation | null;
79
- page?: CanonMessagesPage | null;
80
- message: HostInboundMessage;
81
- senderName: string;
82
- isOwner: boolean;
83
- }): {
84
- participantContext: HostInboundParticipantContext;
85
- behavior?: ResolvedAgentBehaviorPolicy | null;
86
- workSessions: NonNullable<MessageCreatedPayload['workSessions']>;
87
- hydratedFromPage: boolean;
88
- };
89
- export declare function publishHostAgentRuntime(agentId: string, clientType: AgentClientType, runtime: AgentRuntime): Promise<void>;
90
- export declare function publishHostSessionSnapshots(input: {
91
- conversationIds: string[];
92
- agentId: string;
93
- clientType: AgentClientType;
94
- runtime: AgentRuntime;
95
- workspaceOptions: HostWorkspaceResolverOption[];
96
- defaultCwd: string;
97
- liveSessionConfigByConversation?: ReadonlyMap<string, {
98
- model?: string;
99
- permissionMode?: string;
100
- effort?: string;
101
- runtimeControlValues?: Record<string, string>;
102
- workspaceId?: string;
103
- executionMode?: SessionWorkspaceConfig['executionMode'];
104
- executionBranch?: string | null;
105
- }>;
106
- }): Promise<void>;
107
- export declare function readHostSessionConfig<TExtra extends string = never>(raw: unknown, extraStringFields?: readonly TExtra[]): (SessionWorkspaceConfig & Partial<Record<TExtra, string>> & {
108
- runtimeControlValues?: Record<string, string>;
109
- }) | null;
110
- export declare function loadHostSessionConfig<TExtra extends string = never>(input: {
111
- conversationId: string;
112
- agentId: string;
113
- extraStringFields?: readonly TExtra[];
114
- }): Promise<(SessionWorkspaceConfig & Partial<Record<TExtra, string>> & {
115
- runtimeControlValues?: Record<string, string>;
116
- }) | null>;
117
- export declare function resolveHostWorkspaceCwd(input: {
118
- workspaceOptions: HostWorkspaceResolverOption[];
119
- config: {
120
- workspaceId?: string;
121
- retiredWorkspaceConfig?: boolean;
122
- } | null;
123
- defaultCwd: string;
124
- }): string;
125
- export declare function createConversationMetadataLoader(input: {
126
- client: CanonClient;
127
- conversationCache: Map<string, CanonConversation>;
128
- cacheTtlMs?: number;
129
- }): {
130
- refreshConversationCache(force?: boolean): Promise<void>;
131
- getConversationMeta(conversationId: string): Promise<CanonConversation | null>;
132
- };
133
- export {};
@@ -1,265 +0,0 @@
1
- /**
2
- * Host-runtime helpers, inlined from @canonmsg/core.
3
- *
4
- * These helpers glue Canon host wrappers (Codex, Claude Code) to the Canon
5
- * RTDB session/runtime surface. They used to live in
6
- * `@canonmsg/core/src/host-runtime/` but were moved into each plugin in
7
- * media-parity PR C so core does not have to carry host-specific concerns.
8
- *
9
- * Keep this file in lockstep with the equivalent file in
10
- * `packages/claude-code-plugin/src/host-runtime.ts`. If you change the
11
- * behavior here, update that copy too and adjust the shared golden
12
- * fixture test (`packages/codex-plugin/src/host-runtime.test.ts`).
13
- */
14
- import { buildAgentSessionSnapshot, buildConversationWorktreeSpec, buildBehaviorPolicyLines, buildParticipationHistorySnapshot, buildWorkSessionsPromptLines, mergeWorkSessionContexts, normalizeOptionalString, patchAgentSessionSnapshot, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, rtdbRead, rtdbWrite, } from '@canonmsg/core';
15
- export const HOST_ADMISSION_ACTION_CAPABILITIES = Object.freeze({
16
- canStartDirectConversation: false,
17
- canSendContactRequest: false,
18
- canApprovePendingContactRequests: false,
19
- canRejectPendingContactRequests: false,
20
- });
21
- export function buildCanonHostPrompt(input) {
22
- const resolvedWorkSessions = mergeWorkSessionContexts(input.workSession, input.workSessions);
23
- return [
24
- `You are connected to Canon messaging through a ${input.hostLabel} host wrapper.`,
25
- 'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
26
- 'Short intermediate assistant messages may be shown as ephemeral status while you work.',
27
- ...input.buildInboundContextLines(input.participantContext),
28
- ...buildBehaviorPolicyLines(input.behavior),
29
- ...buildWorkSessionsPromptLines(resolvedWorkSessions),
30
- 'Canon participants may be humans or AI agents.',
31
- 'Honor the Canon behavior policy above when deciding how proactively to participate.',
32
- ...(resolvedWorkSessions.length > 0
33
- ? ['Honor the Canon work-session context above within its stated disclosure limits.']
34
- : []),
35
- `Conversation ID: ${input.conversationId}`,
36
- '',
37
- 'New Canon message:',
38
- input.content,
39
- ].join('\n');
40
- }
41
- /**
42
- * Render the **text portion** of an inbound Canon message. Images are
43
- * referenced by short placeholders — their actual bytes are delivered to the
44
- * host as native vision/media inputs (Codex `-i <file>`, Anthropic image
45
- * blocks). URLs are intentionally *not* inlined, since the harness never
46
- * needs to refetch and earlier `[Image: <url>]` inlining caused vision
47
- * models to see a string about an image instead of the image itself.
48
- *
49
- * `materialized` may be passed so non-image attachments can reference a
50
- * local path the agent can Read. Without it we fall back to an unadorned
51
- * placeholder; the vision path still works because image args carry the
52
- * file path directly.
53
- */
54
- export function renderCanonHostInboundContent(message, materialized) {
55
- const body = message.text || '';
56
- const placeholders = [];
57
- const attachments = message.attachments ?? [];
58
- for (let i = 0; i < attachments.length; i += 1) {
59
- const att = attachments[i];
60
- const mat = materialized?.find((m) => m.index === i) ?? null;
61
- placeholders.push(describeAttachment(att, mat));
62
- }
63
- if (message.contentType === 'contact_card' && message.contactCard) {
64
- placeholders.push(describeContactCard(message.contactCard));
65
- }
66
- const rendered = [...placeholders, body].filter(Boolean).join('\n');
67
- return rendered || '[Empty message]';
68
- }
69
- function describeContactCard(card) {
70
- const parts = [`${card.userType} · userId: ${card.userId}`];
71
- if (card.accessLevel)
72
- parts.push(`access: ${card.accessLevel}`);
73
- if (card.ownerName)
74
- parts.push(`owner: ${card.ownerName}`);
75
- if (card.about)
76
- parts.push(`about: ${card.about}`);
77
- const identity = `📇 Contact card: "${card.displayName}" (${parts.join(' · ')}).`;
78
- const missingCapabilities = [
79
- !HOST_ADMISSION_ACTION_CAPABILITIES.canStartDirectConversation
80
- ? 'start a direct conversation'
81
- : null,
82
- !HOST_ADMISSION_ACTION_CAPABILITIES.canSendContactRequest
83
- ? 'send a contact request'
84
- : null,
85
- !HOST_ADMISSION_ACTION_CAPABILITIES.canApprovePendingContactRequests
86
- ? 'approve pending requests'
87
- : null,
88
- !HOST_ADMISSION_ACTION_CAPABILITIES.canRejectPendingContactRequests
89
- ? 'reject pending requests'
90
- : null,
91
- ].filter(Boolean).join(', ');
92
- const hint = `This host can inspect the card, but Canon admission actions are missing here. Missing capabilities: ${missingCapabilities}. Use another Canon surface for userId ${card.userId}.`;
93
- return `${identity}\n${hint}`;
94
- }
95
- function describeAttachment(attachment, materialized) {
96
- if (attachment.kind === 'image') {
97
- return '[Image attached]';
98
- }
99
- if (attachment.kind === 'audio') {
100
- const durationMs = materialized?.durationMs ?? attachment.durationMs;
101
- const duration = durationMs ? ` (${Math.round(durationMs / 1000)}s)` : '';
102
- const ref = materialized?.path ? ` ${materialized.path}` : '';
103
- return `[Voice message${duration}${ref}]`;
104
- }
105
- // file
106
- const label = materialized?.fileName ?? attachment.fileName ?? 'File';
107
- const ref = materialized?.path ? ` ${materialized.path}` : '';
108
- return `[File: ${label}${ref}]`;
109
- }
110
- export function buildHydratedInboundContext(input) {
111
- const history = buildParticipationHistorySnapshot(input.page?.messages ?? [], input.agentId);
112
- return {
113
- participantContext: {
114
- conversationType: input.conversation?.type ?? 'unknown',
115
- memberCount: input.conversation?.memberIds?.length ?? null,
116
- senderType: input.message.senderType ?? 'human',
117
- senderName: input.senderName,
118
- isOwner: input.isOwner,
119
- mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(input.agentId),
120
- recentSenderTypes: history.recentSenderTypes,
121
- recentHumanCount: history.recentHumanCount,
122
- recentAgentCount: history.recentAgentCount,
123
- consecutiveAgentTurns: history.consecutiveAgentTurns,
124
- currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
125
- },
126
- behavior: input.page?.behavior ?? input.conversation?.behavior,
127
- workSessions: input.page?.workSessions ?? [],
128
- hydratedFromPage: input.page != null,
129
- };
130
- }
131
- export async function publishHostAgentRuntime(agentId, clientType, runtime) {
132
- await rtdbWrite(`/agent-runtime/${agentId}`, {
133
- clientType,
134
- hostMode: true,
135
- ...runtime,
136
- updatedAt: { '.sv': 'timestamp' },
137
- });
138
- }
139
- export async function publishHostSessionSnapshots(input) {
140
- if (input.conversationIds.length === 0) {
141
- return;
142
- }
143
- await Promise.all(input.conversationIds.map(async (conversationId) => {
144
- const persistedConfig = await loadHostSessionConfig({
145
- conversationId,
146
- agentId: input.agentId,
147
- extraStringFields: ['permissionMode'],
148
- });
149
- const liveConfig = input.liveSessionConfigByConversation?.get(conversationId) ?? null;
150
- const mergedConfig = {
151
- ...(persistedConfig ?? {}),
152
- ...(liveConfig ?? {}),
153
- };
154
- const snapshot = buildAgentSessionSnapshot({
155
- conversationId,
156
- agentId: input.agentId,
157
- runtime: {
158
- ...input.runtime,
159
- clientType: input.clientType,
160
- hostMode: true,
161
- },
162
- sessionConfig: {
163
- ...(mergedConfig.model ? { model: mergedConfig.model } : {}),
164
- ...(mergedConfig.permissionMode ? { permissionMode: mergedConfig.permissionMode } : {}),
165
- ...(mergedConfig.effort ? { effort: mergedConfig.effort } : {}),
166
- ...(mergedConfig.runtimeControlValues
167
- ? { runtimeControlValues: mergedConfig.runtimeControlValues }
168
- : {}),
169
- ...(mergedConfig.workspaceId ? { workspaceId: mergedConfig.workspaceId } : {}),
170
- ...(mergedConfig.executionMode ? { executionMode: mergedConfig.executionMode } : {}),
171
- },
172
- lastHeartbeatAt: undefined,
173
- });
174
- let executionBranch = liveConfig?.executionBranch ?? null;
175
- if (!executionBranch && snapshot.executionMode === 'worktree' && snapshot.workspaceId) {
176
- const workspace = input.workspaceOptions.find((option) => option.id === snapshot.workspaceId);
177
- if (workspace) {
178
- executionBranch = buildConversationWorktreeSpec({
179
- agentId: input.agentId,
180
- conversationId,
181
- workspaceCwd: workspace.cwd,
182
- }).branch;
183
- }
184
- }
185
- return patchAgentSessionSnapshot(conversationId, input.agentId, {
186
- clientType: input.clientType,
187
- hostMode: true,
188
- model: snapshot.model ?? null,
189
- permissionMode: snapshot.permissionMode ?? null,
190
- effort: snapshot.effort ?? null,
191
- runtimeControlValues: snapshot.runtimeControlValues ?? null,
192
- workspaceId: snapshot.workspaceId ?? null,
193
- executionMode: snapshot.executionMode ?? null,
194
- executionBranch,
195
- modelOptions: snapshot.modelOptions,
196
- permissionModeOptions: snapshot.permissionModeOptions,
197
- workspaceOptions: snapshot.workspaceOptions,
198
- availableExecutionModes: snapshot.availableExecutionModes,
199
- lastHeartbeatAt: { '.sv': 'timestamp' },
200
- });
201
- }));
202
- }
203
- export function readHostSessionConfig(raw, extraStringFields = []) {
204
- const baseConfig = readSessionWorkspaceConfig(raw);
205
- if (!raw || typeof raw !== 'object') {
206
- return baseConfig;
207
- }
208
- const data = raw;
209
- const extraConfig = Object.fromEntries(extraStringFields.flatMap((field) => {
210
- const value = normalizeOptionalString(data[field]);
211
- return value ? [[field, value]] : [];
212
- }));
213
- const runtimeControlValues = Object.fromEntries(Object.entries(data.runtimeControlValues && typeof data.runtimeControlValues === 'object'
214
- ? data.runtimeControlValues
215
- : {}).flatMap(([key, value]) => {
216
- const normalizedValue = normalizeOptionalString(value);
217
- return normalizedValue ? [[key, normalizedValue]] : [];
218
- }));
219
- return {
220
- ...(baseConfig ?? {}),
221
- ...extraConfig,
222
- ...(Object.keys(runtimeControlValues).length > 0 ? { runtimeControlValues } : {}),
223
- };
224
- }
225
- export async function loadHostSessionConfig(input) {
226
- const raw = await rtdbRead(`/session-config/${input.conversationId}/${input.agentId}`);
227
- return readHostSessionConfig(raw, input.extraStringFields);
228
- }
229
- export function resolveHostWorkspaceCwd(input) {
230
- return resolveConfiguredWorkspaceCwd(input);
231
- }
232
- export function createConversationMetadataLoader(input) {
233
- const cacheTtlMs = input.cacheTtlMs ?? 10_000;
234
- let conversationCacheLoadedAt = 0;
235
- async function refreshConversationCache(force = false) {
236
- if (!force
237
- && input.conversationCache.size > 0
238
- && Date.now() - conversationCacheLoadedAt < cacheTtlMs) {
239
- return;
240
- }
241
- const conversations = await input.client.getConversations();
242
- input.conversationCache.clear();
243
- for (const conversation of conversations) {
244
- input.conversationCache.set(conversation.id, conversation);
245
- }
246
- conversationCacheLoadedAt = Date.now();
247
- }
248
- async function getConversationMeta(conversationId) {
249
- try {
250
- await refreshConversationCache();
251
- const cached = input.conversationCache.get(conversationId);
252
- if (cached)
253
- return cached;
254
- await refreshConversationCache(true);
255
- return input.conversationCache.get(conversationId) ?? null;
256
- }
257
- catch {
258
- return input.conversationCache.get(conversationId) ?? null;
259
- }
260
- }
261
- return {
262
- refreshConversationCache,
263
- getConversationMeta,
264
- };
265
- }