@canonmsg/claude-code-plugin 0.12.0 → 0.12.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
@@ -15,7 +15,15 @@ canon-register --name "My Claude" --description "My Claude Code agent" --phone "
15
15
  canon-claude
16
16
  ```
17
17
 
18
- Send a message to your agent in the Canon app. Claude Code responds with full capabilities (file read/write, bash, etc.).
18
+ Send a message to your agent in the Canon app. Claude Code responds with full capabilities (file read/write, bash, etc.). Keep the `canon-claude` terminal open; closing it takes this local agent offline.
19
+
20
+ After a restart or closed terminal, use the shared local manager to list and revive all recorded local agents:
21
+
22
+ ```bash
23
+ npm install -g @canonmsg/local-agents
24
+ canon-necromance
25
+ canon-necromance revive frontend
26
+ ```
19
27
 
20
28
  ## Features
21
29
 
@@ -60,6 +68,8 @@ CANON_AGENT=frontend canon-claude --cwd ~/projects/frontend
60
68
 
61
69
  For a lighter integration without session controls, run Canon as a channel plugin inside your own Claude Code session. See [full setup guide](../../docs/claude-code-setup.md#channel-mode-alternative).
62
70
 
71
+ Channel mode is embedded inside Claude Code and is shown as non-revivable by `canon-necromance`. Use `canon-claude` host mode when you want phone-controlled runtime sessions.
72
+
63
73
  ## Development
64
74
 
65
75
  ```bash
package/dist/host.js CHANGED
@@ -26,8 +26,7 @@ import { execFileSync } from 'node:child_process';
26
26
  import { existsSync } from 'node:fs';
27
27
  import { query, } from '@anthropic-ai/claude-agent-sdk';
28
28
  import { isAnthropicImageAttachment, materializeMessageMedia, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
29
- import { CLAUDE_PERMISSION_MODE_OPTIONS, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildPlanApprovalRequest, buildQuestionRequest, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeRuntimeInfo, writeSessionState, clearSessionState, writeTurnState, releaseConversationEnvironment, } from '@canonmsg/core';
30
- import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
29
+ import { CLAUDE_PERMISSION_MODE_OPTIONS, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, buildPlanApprovalRequest, buildQuestionRequest, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, rtdbRead, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
31
30
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
32
31
  import { createStreamingBuffer } from './streaming-buffer.js';
33
32
  import { runCli } from './cli-entry.js';
@@ -47,76 +46,16 @@ let runtimeMetadata = {
47
46
  };
48
47
  let runtimeMetadataRefresh = null;
49
48
  function buildClaudeRuntimeDescriptor(input) {
50
- return {
51
- coreControls: [
52
- {
53
- id: 'model',
54
- label: 'Model',
55
- options: input.models,
56
- defaultValue: input.models[0]?.value ?? null,
57
- availability: 'setup_and_live',
58
- liveBehavior: 'immediate',
59
- selectionPolicy: 'inherit',
60
- },
61
- {
62
- id: 'workspace',
63
- label: 'Project',
64
- options: input.workspaces.map((workspace) => ({
65
- value: workspace.id,
66
- label: workspace.label,
67
- ...(workspace.description ? { description: workspace.description } : {}),
68
- ...(workspace.workspaceRootId ? { workspaceRootId: workspace.workspaceRootId } : {}),
69
- ...(workspace.workspaceRelativePath ? { workspaceRelativePath: workspace.workspaceRelativePath } : {}),
70
- ...(workspace.source ? { source: workspace.source } : {}),
71
- })),
72
- defaultValue: input.workspaces[0]?.id ?? null,
73
- availability: 'setup',
74
- liveBehavior: 'none',
75
- selectionPolicy: 'inherit',
76
- description: input.workspaceRoots?.length
77
- ? 'Choose one of the projects discovered inside the approved local roots for this host.'
78
- : 'Choose one of the local projects advertised by this host.',
79
- },
80
- {
81
- id: 'executionMode',
82
- label: 'Execution mode',
83
- options: input.executionModes.map((mode) => ({
84
- value: mode,
85
- label: mode === 'worktree' ? 'Isolated worktree' : 'Use shared project',
86
- description: mode === 'worktree'
87
- ? 'Creates or reuses a per-conversation git worktree under ~/.canon/conversation-worktrees when the selected project is a git repo.'
88
- : 'Runs directly in the selected project folder. Changes happen there.',
89
- })),
90
- defaultValue: null,
91
- availability: 'setup',
92
- liveBehavior: 'none',
93
- selectionPolicy: 'required_explicit',
94
- },
95
- ],
96
- runtimeControls: [
97
- {
98
- id: 'permissionMode',
99
- label: 'Permission mode',
100
- options: [...CLAUDE_PERMISSION_MODE_OPTIONS],
101
- defaultValue: 'default',
102
- availability: 'setup_and_live',
103
- liveBehavior: 'immediate',
104
- selectionPolicy: 'inherit',
105
- },
106
- {
107
- id: 'effort',
108
- label: 'Thinking level',
109
- options: [
110
- { value: 'low', label: 'Low' },
111
- { value: 'medium', label: 'Medium' },
112
- { value: 'high', label: 'High' },
113
- ],
114
- defaultValue: 'medium',
115
- availability: 'setup_and_live',
116
- liveBehavior: 'immediate',
117
- selectionPolicy: 'inherit',
118
- },
119
- ],
49
+ return buildFirstPartyCodingRuntimeDescriptor({
50
+ clientType: 'claude-code',
51
+ models: input.models,
52
+ workspaces: input.workspaces,
53
+ workspaceRoots: input.workspaceRoots,
54
+ executionModes: input.executionModes,
55
+ permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
56
+ defaultPermissionMode: 'default',
57
+ streamingTextMode: 'delta',
58
+ admissionActions: HOST_ADMISSION_ACTIONS_DISABLED,
120
59
  actions: [
121
60
  {
122
61
  id: 'plan',
@@ -204,10 +143,7 @@ function buildClaudeRuntimeDescriptor(input) {
204
143
  dispatch: { kind: 'signal', signal: 'interrupt' },
205
144
  },
206
145
  ],
207
- workspaceRoots: input.workspaceRoots,
208
- supportsInterrupt: true,
209
- streamingTextMode: 'delta',
210
- };
146
+ });
211
147
  }
212
148
  function normalizeClaudeEffortLevel(value) {
213
149
  return value === 'low' || value === 'medium' || value === 'high'
@@ -549,7 +485,7 @@ async function buildCanonUserContent(input) {
549
485
  ];
550
486
  }
551
487
  // ── Session factory ─────────────────────────────────────────────────
552
- function createSession(conversationId, environment, agentId, client, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
488
+ function createSession(conversationId, environment, agentId, client, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
553
489
  const { cwd } = environment;
554
490
  // Per-session input stream with message queue
555
491
  let resolveInput = null;
@@ -756,10 +692,9 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
756
692
  session.streamingTimer = timer;
757
693
  },
758
694
  onWrite: (text) => {
759
- rtdbWrite(`/streaming/${conversationId}/${agentId}`, {
695
+ runtimeState.writeStreaming(conversationId, {
760
696
  text,
761
697
  status: 'streaming',
762
- updatedAt: { '.sv': 'timestamp' },
763
698
  }).catch(() => { });
764
699
  },
765
700
  });
@@ -772,7 +707,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
772
707
  }
773
708
  function clearStreaming() {
774
709
  streamingBuffer.clear();
775
- rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
710
+ runtimeState.clearStreaming(conversationId).catch(() => { });
776
711
  }
777
712
  function clearIdleResetTimer() {
778
713
  if (!session.idleResetTimer)
@@ -861,7 +796,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
861
796
  }, FINAL_DELIVERY_RETRY_MS);
862
797
  }
863
798
  function writeState() {
864
- writeSessionState(conversationId, agentId, {
799
+ runtimeState.writeSessionState(conversationId, {
865
800
  ...session.state,
866
801
  ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
867
802
  cwd,
@@ -876,7 +811,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
876
811
  }).catch(() => { });
877
812
  }
878
813
  function writeTurn() {
879
- writeTurnState(conversationId, agentId, {
814
+ runtimeState.writeTurnState(conversationId, {
880
815
  turnId: session.currentTurnId,
881
816
  state: session.turnState,
882
817
  queueDepth: session.pendingInputs.length,
@@ -1137,7 +1072,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
1137
1072
  executionModes: config.availableExecutionModes,
1138
1073
  }),
1139
1074
  });
1140
- writeSessionState(conversationId, agentId, {
1075
+ runtimeState.writeSessionState(conversationId, {
1141
1076
  ...session.state,
1142
1077
  availableModels: modelList,
1143
1078
  cwd,
@@ -1158,7 +1093,13 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
1158
1093
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${resumeSessionId ? 'resumed' : 'started'} `
1159
1094
  + `(mode: ${environment.mode}, cwd: ${cwd})`);
1160
1095
  };
1161
- init().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] Init failed:`, err));
1096
+ init().catch((err) => {
1097
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Init failed:`, err);
1098
+ if (resumeSessionId) {
1099
+ session.sdkSessionId = null;
1100
+ onSessionEnd(conversationId, null);
1101
+ }
1102
+ });
1162
1103
  return session;
1163
1104
  }
1164
1105
  // ── Main ────────────────────────────────────────────────────────────
@@ -1186,10 +1127,10 @@ export async function main() {
1186
1127
  for (const warning of workspaceDiscovery.warnings) {
1187
1128
  console.error(`[canon-host] ${warning}`);
1188
1129
  }
1189
- const { apiKey, agentId: profileAgentId, profile } = resolveCanonAgent({ logPrefix: '[canon-host]' });
1130
+ const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
1190
1131
  console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
1191
1132
  // ── Canon API client + RTDB auth ──
1192
- const client = new CanonClient(apiKey);
1133
+ const client = new CanonClient(apiKey, baseUrl);
1193
1134
  initRTDBAuth(client);
1194
1135
  let agentId;
1195
1136
  let ownerId = null;
@@ -1209,6 +1150,39 @@ export async function main() {
1209
1150
  }
1210
1151
  console.error(`[canon-host] Authenticated as ${agentId}`);
1211
1152
  }
1153
+ const launchArgs = [...process.argv.slice(2)];
1154
+ if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
1155
+ launchArgs.push('--cwd', workingDir);
1156
+ }
1157
+ const runtimeId = buildLocalRuntimeId({
1158
+ runtime: 'claude-code',
1159
+ profile,
1160
+ cwd: workingDir,
1161
+ launchCommand: ['canon-claude', ...launchArgs],
1162
+ });
1163
+ upsertLocalRuntimeEntry({
1164
+ id: runtimeId,
1165
+ runtime: 'claude-code',
1166
+ profile,
1167
+ agentId,
1168
+ agentName: profileAgentName,
1169
+ cwd: workingDir,
1170
+ baseCwd: workingDir,
1171
+ workspaceRoots: workspaceRoots.map((root) => root.cwd),
1172
+ workspaces: workspaceOptions.map((workspace) => workspace.cwd),
1173
+ launchCommand: ['canon-claude', ...launchArgs],
1174
+ pid: process.pid,
1175
+ status: profile ? 'running' : 'manual',
1176
+ reviveCapability: profile ? 'revivable' : 'manual',
1177
+ surfaceMode: 'host',
1178
+ lastStartedAt: new Date().toISOString(),
1179
+ lastHeartbeatAt: new Date().toISOString(),
1180
+ });
1181
+ const runtimeState = createRuntimeStatePublisher({
1182
+ agentId,
1183
+ clientType: 'claude-code',
1184
+ hostMode: true,
1185
+ });
1212
1186
  let streamConnected = false;
1213
1187
  const hostAvailableExecutionModes = [
1214
1188
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1230,6 +1204,12 @@ export async function main() {
1230
1204
  const knownConversationIds = new Set();
1231
1205
  let lastKnownConversationRefreshAt = 0;
1232
1206
  const publishRuntimeHeartbeat = async () => {
1207
+ heartbeatLocalRuntimeEntry(runtimeId, {
1208
+ agentId,
1209
+ agentName: profileAgentName,
1210
+ cwd: workingDir,
1211
+ baseCwd: workingDir,
1212
+ });
1233
1213
  if (!streamConnected)
1234
1214
  return;
1235
1215
  await refreshKnownConversationIds().catch((error) => {
@@ -1253,6 +1233,7 @@ export async function main() {
1253
1233
  runtime: runtimeDescriptor,
1254
1234
  workspaceOptions,
1255
1235
  defaultCwd: workingDir,
1236
+ extraSessionConfigFields: ['permissionMode', 'effort'],
1256
1237
  liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
1257
1238
  const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
1258
1239
  return [
@@ -1293,7 +1274,7 @@ export async function main() {
1293
1274
  worktreePath: session?.environment.worktreePath ?? null,
1294
1275
  fallbackReason: resolveExecutionFallbackReason(session?.environment),
1295
1276
  });
1296
- await writeRuntimeInfo(conversationId, agentId, payload);
1277
+ await runtimeState.writeRuntimeInfo(conversationId, payload);
1297
1278
  })).catch((error) => {
1298
1279
  console.error('[canon-host] Failed to publish runtime info:', error);
1299
1280
  });
@@ -1395,11 +1376,17 @@ export async function main() {
1395
1376
  // Save SDK session ID for future resume
1396
1377
  if (session.sdkSessionId) {
1397
1378
  savedSessionIds.set(buildConversationEnvironmentKey(conversationId, session.environment.baseCwd), session.sdkSessionId);
1379
+ saveRuntimeSessionState(runtimeId, {
1380
+ conversationId,
1381
+ baseCwd: session.environment.baseCwd,
1382
+ executionMode: session.environment.mode,
1383
+ claudeSessionId: session.sdkSessionId,
1384
+ });
1398
1385
  }
1399
1386
  if (session.streamingTimer)
1400
1387
  clearTimeout(session.streamingTimer);
1401
1388
  session.streamingText = '';
1402
- rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
1389
+ runtimeState.clearStreaming(conversationId).catch(() => { });
1403
1390
  if (session.idleResetTimer) {
1404
1391
  clearTimeout(session.idleResetTimer);
1405
1392
  session.idleResetTimer = null;
@@ -1408,8 +1395,8 @@ export async function main() {
1408
1395
  clearTimeout(session.finalDeliveryTimer);
1409
1396
  session.finalDeliveryTimer = null;
1410
1397
  }
1411
- clearSessionState(conversationId, agentId).catch(() => { });
1412
- clearTurnState(conversationId, agentId).catch(() => { });
1398
+ runtimeState.clearSessionState(conversationId).catch(() => { });
1399
+ runtimeState.clearTurnState(conversationId).catch(() => { });
1413
1400
  client.setTyping(conversationId, false).catch(() => { });
1414
1401
  try {
1415
1402
  session.query.close();
@@ -1457,19 +1444,37 @@ export async function main() {
1457
1444
  });
1458
1445
  try {
1459
1446
  const resumeKey = buildConversationEnvironmentKey(conversationId, environment.baseCwd);
1460
- const resumeId = savedSessionIds.get(resumeKey);
1461
- const session = createSession(conversationId, environment, agentId, client, (convoId, sdkSessionId) => {
1447
+ const persisted = loadRuntimeSessionState(runtimeId, {
1448
+ conversationId,
1449
+ baseCwd: environment.baseCwd,
1450
+ executionMode: environment.mode,
1451
+ });
1452
+ const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
1453
+ const session = createSession(conversationId, environment, agentId, client, runtimeState, (convoId, sdkSessionId) => {
1462
1454
  // Called when session processing loop ends — save session ID for resume
1463
1455
  const existing = sessions.get(convoId);
1464
1456
  if (existing) {
1465
1457
  releaseConversationEnvironment(existing.environment);
1466
1458
  if (sdkSessionId) {
1467
1459
  savedSessionIds.set(buildConversationEnvironmentKey(convoId, existing.environment.baseCwd), sdkSessionId);
1460
+ saveRuntimeSessionState(runtimeId, {
1461
+ conversationId: convoId,
1462
+ baseCwd: existing.environment.baseCwd,
1463
+ executionMode: existing.environment.mode,
1464
+ claudeSessionId: sdkSessionId,
1465
+ });
1466
+ }
1467
+ else {
1468
+ clearRuntimeSessionState(runtimeId, {
1469
+ conversationId: convoId,
1470
+ baseCwd: existing.environment.baseCwd,
1471
+ executionMode: existing.environment.mode,
1472
+ });
1468
1473
  }
1469
1474
  }
1470
1475
  sessions.delete(convoId);
1471
- clearSessionState(convoId, agentId).catch(() => { });
1472
- clearTurnState(convoId, agentId).catch(() => { });
1476
+ runtimeState.clearSessionState(convoId).catch(() => { });
1477
+ runtimeState.clearTurnState(convoId).catch(() => { });
1473
1478
  }, (descriptor) => {
1474
1479
  runtimeDescriptor = descriptor;
1475
1480
  void publishRuntimeHeartbeat();
@@ -1496,6 +1501,112 @@ export async function main() {
1496
1501
  pendingSessionCreations.delete(conversationId);
1497
1502
  }
1498
1503
  }
1504
+ async function enqueueRecoveredInboundMessage(input) {
1505
+ const m = input.message;
1506
+ if (m.senderId === agentId)
1507
+ return;
1508
+ let materialized = [];
1509
+ if (m.id) {
1510
+ try {
1511
+ materialized = await materializeMessageMedia({
1512
+ id: m.id,
1513
+ attachments: m.attachments ?? [],
1514
+ }, { agentId, conversationId: input.conversationId });
1515
+ }
1516
+ catch (error) {
1517
+ console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to materialize recovered media:`, error instanceof Error ? error.message : error);
1518
+ }
1519
+ }
1520
+ const sender = m.senderName || m.senderId;
1521
+ const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
1522
+ const content = renderInboundContent(m, materialized);
1523
+ const hydrated = await loadHydratedInboundContext({
1524
+ conversationId: input.conversationId,
1525
+ message: m,
1526
+ senderName: sender,
1527
+ isOwner,
1528
+ });
1529
+ const behavior = input.hydratedPage?.behavior ?? hydrated.behavior;
1530
+ const workSessions = input.hydratedPage?.workSessions ?? hydrated.workSessions;
1531
+ const participantContext = hydrated.participantContext;
1532
+ const autoReply = decideAutoReply(participantContext, behavior);
1533
+ if (!autoReply.allow) {
1534
+ console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Suppressed recovered auto-reply: ${autoReply.reason}`);
1535
+ return;
1536
+ }
1537
+ let session;
1538
+ try {
1539
+ session = await getOrCreateSession(input.conversationId);
1540
+ }
1541
+ catch (error) {
1542
+ const message = error instanceof Error ? error.message : String(error);
1543
+ const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
1544
+ console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
1545
+ await client.sendMessage(input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
1546
+ metadata: {
1547
+ turnSemantics: 'turn_complete',
1548
+ turnComplete: true,
1549
+ },
1550
+ }).catch(() => { });
1551
+ return;
1552
+ }
1553
+ const turnMetadata = normalizeTurnMetadata(m.metadata);
1554
+ const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
1555
+ const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
1556
+ const promptText = buildCanonPrompt({
1557
+ content,
1558
+ conversationId: input.conversationId,
1559
+ participantContext,
1560
+ behavior,
1561
+ workSession: m.workSession,
1562
+ workSessions,
1563
+ });
1564
+ const messageContent = await buildCanonUserContent({ promptText, materialized });
1565
+ session.enqueueInbound({
1566
+ type: 'user',
1567
+ message: {
1568
+ role: 'user',
1569
+ content: messageContent,
1570
+ },
1571
+ parent_tool_use_id: null,
1572
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted);
1573
+ if (m.id) {
1574
+ saveRuntimeSessionState(runtimeId, {
1575
+ conversationId: input.conversationId,
1576
+ baseCwd: workingDir,
1577
+ lastInboundMessageId: m.id,
1578
+ });
1579
+ }
1580
+ }
1581
+ try {
1582
+ for (const conversation of conversationCache.values()) {
1583
+ const cursor = loadRuntimeSessionState(runtimeId, {
1584
+ conversationId: conversation.id,
1585
+ baseCwd: workingDir,
1586
+ })?.lastInboundMessageId;
1587
+ const page = await client.getMessagesPage(conversation.id, 25);
1588
+ const inboundMessages = page.messages
1589
+ .filter((message) => message.senderId !== agentId)
1590
+ .sort((a, b) => String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')));
1591
+ const cursorIndex = cursor
1592
+ ? inboundMessages.findIndex((message) => message.id === cursor)
1593
+ : -1;
1594
+ const messagesToRecover = cursorIndex >= 0
1595
+ ? inboundMessages.slice(cursorIndex + 1)
1596
+ : inboundMessages.slice(-1);
1597
+ for (const message of messagesToRecover) {
1598
+ console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Recovering inbound message on startup`);
1599
+ await enqueueRecoveredInboundMessage({
1600
+ conversationId: conversation.id,
1601
+ message: message,
1602
+ hydratedPage: page,
1603
+ });
1604
+ }
1605
+ }
1606
+ }
1607
+ catch (error) {
1608
+ console.error('[canon-host] Failed startup recovery:', error instanceof Error ? error.message : error);
1609
+ }
1499
1610
  // ── Connect SSE stream for inbound Canon messages ──
1500
1611
  const stream = new CanonStream({
1501
1612
  apiKey,
@@ -1607,6 +1718,13 @@ export async function main() {
1607
1718
  },
1608
1719
  parent_tool_use_id: null,
1609
1720
  }, deliveryIntent, m.id ?? null, shouldMarkAccepted);
1721
+ if (m.id) {
1722
+ saveRuntimeSessionState(runtimeId, {
1723
+ conversationId: convoId,
1724
+ baseCwd: workingDir,
1725
+ lastInboundMessageId: m.id,
1726
+ });
1727
+ }
1610
1728
  })();
1611
1729
  },
1612
1730
  onConnected: () => {
@@ -1616,7 +1734,7 @@ export async function main() {
1616
1734
  },
1617
1735
  onDisconnected: () => {
1618
1736
  streamConnected = false;
1619
- rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
1737
+ runtimeState.clearAgentRuntime().catch(() => { });
1620
1738
  console.error('[canon-host] SSE disconnected');
1621
1739
  },
1622
1740
  onError: (err) => console.error(`[canon-host] SSE error: ${err.message}`),
@@ -1674,7 +1792,7 @@ export async function main() {
1674
1792
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err);
1675
1793
  }
1676
1794
  }
1677
- writeSessionState(convoId, agentId, {
1795
+ runtimeState.writeSessionState(convoId, {
1678
1796
  ...session.state,
1679
1797
  ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
1680
1798
  cwd: session.cwd,
@@ -1702,7 +1820,7 @@ export async function main() {
1702
1820
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Interrupt signal`);
1703
1821
  try {
1704
1822
  session.turnState = 'interrupted';
1705
- writeTurnState(convoId, agentId, {
1823
+ runtimeState.writeTurnState(convoId, {
1706
1824
  turnId: session.currentTurnId,
1707
1825
  state: 'interrupted',
1708
1826
  queueDepth: session.pendingInputs.length,
@@ -1718,7 +1836,7 @@ export async function main() {
1718
1836
  session.streamingTimer = null;
1719
1837
  }
1720
1838
  session.streamingText = '';
1721
- rtdbWrite(`/streaming/${convoId}/${agentId}`, null).catch(() => { });
1839
+ runtimeState.clearStreaming(convoId).catch(() => { });
1722
1840
  client.setTyping(convoId, false).catch(() => { });
1723
1841
  }
1724
1842
  catch (err) {
@@ -1739,7 +1857,7 @@ export async function main() {
1739
1857
  // ── Heartbeat (per-session) ──
1740
1858
  const heartbeat = setInterval(() => {
1741
1859
  for (const session of sessions.values()) {
1742
- writeSessionState(session.conversationId, agentId, {
1860
+ runtimeState.writeSessionState(session.conversationId, {
1743
1861
  ...session.state,
1744
1862
  ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
1745
1863
  cwd: session.cwd,
@@ -1749,7 +1867,7 @@ export async function main() {
1749
1867
  hostMode: true,
1750
1868
  isActive: true,
1751
1869
  }).catch(() => { });
1752
- writeTurnState(session.conversationId, agentId, {
1870
+ runtimeState.writeTurnState(session.conversationId, {
1753
1871
  turnId: session.currentTurnId,
1754
1872
  state: session.turnState,
1755
1873
  queueDepth: session.pendingInputs.length,
@@ -1784,17 +1902,17 @@ export async function main() {
1784
1902
  clearInterval(heartbeat);
1785
1903
  clearInterval(idleCheck);
1786
1904
  stream.stop();
1787
- await rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
1905
+ await runtimeState.clearAgentRuntime().catch(() => { });
1788
1906
  for (const convoId of [...sessions.keys()]) {
1789
1907
  closeSession(convoId);
1790
1908
  }
1791
- const ap = getActiveProfile();
1792
- if (ap)
1793
- releaseLock(ap);
1909
+ markLocalRuntimeStopped(runtimeId);
1910
+ (lockHandle ?? getActiveProfileLock())?.release();
1794
1911
  process.exit(0);
1795
1912
  };
1796
1913
  process.on('SIGINT', shutdown);
1797
1914
  process.on('SIGTERM', shutdown);
1915
+ process.on('SIGHUP', shutdown);
1798
1916
  console.error('[canon-host] Ready — sessions created on demand');
1799
1917
  // Keep alive
1800
1918
  await new Promise(() => { });
@@ -1802,8 +1920,6 @@ export async function main() {
1802
1920
  // ── Entry ───────────────────────────────────────────────────────────
1803
1921
  runCli(import.meta.url, main, (err) => {
1804
1922
  console.error('[canon-host] Fatal error:', err);
1805
- const ap = getActiveProfile();
1806
- if (ap)
1807
- releaseLock(ap);
1923
+ getActiveProfileLock()?.release();
1808
1924
  process.exit(1);
1809
1925
  });
package/dist/register.js CHANGED
@@ -9,14 +9,10 @@ import { setDefaultResultOrder } from 'node:dns';
9
9
  *
10
10
  * On approval, saves the agent to ~/.canon/agents.json under the profile name.
11
11
  */
12
- import { registerAndWaitForApproval } from '@canonmsg/core';
13
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
14
- import { join } from 'node:path';
15
- import { homedir } from 'node:os';
12
+ import { ackRegistrationApproval, clearPendingRegistration, getOrCreatePendingRegistration, registerAndWaitForApproval, updatePendingRegistration, upsertAgentProfile, AGENTS_PATH, } from '@canonmsg/core';
13
+ import { readFileSync } from 'node:fs';
16
14
  import { parseArgs } from 'node:util';
17
15
  import { runCli } from './cli-entry.js';
18
- const CANON_DIR = join(homedir(), '.canon');
19
- const AGENTS_PATH = join(CANON_DIR, 'agents.json');
20
16
  export async function main() {
21
17
  setDefaultResultOrder('ipv4first');
22
18
  const { values } = parseArgs({
@@ -44,6 +40,7 @@ export async function main() {
44
40
  // File doesn't exist yet
45
41
  }
46
42
  console.log(`Registering agent "${values.name}" (profile: ${profileName})...`);
43
+ const pending = getOrCreatePendingRegistration(profileName, 'claude-code');
47
44
  const result = await registerAndWaitForApproval({
48
45
  name: values.name,
49
46
  description: values.description,
@@ -52,8 +49,14 @@ export async function main() {
52
49
  clientType: 'claude-code',
53
50
  baseUrl: values['base-url'],
54
51
  requestedAgentId: existingAgentId,
52
+ localRegistrationId: pending.localRegistrationId,
55
53
  }, {
56
- onSubmitted: (requestId) => {
54
+ onSubmitted: (requestId, pollToken) => {
55
+ updatePendingRegistration(profileName, {
56
+ requestId,
57
+ pollToken,
58
+ clientType: 'claude-code',
59
+ });
57
60
  console.log(`Registration submitted (request ID: ${requestId}).`);
58
61
  console.log('Waiting for approval in Canon app...');
59
62
  },
@@ -64,25 +67,26 @@ export async function main() {
64
67
  console.log(''); // newline after dots
65
68
  switch (result.status) {
66
69
  case 'approved': {
67
- // Save to ~/.canon/agents.json
68
- mkdirSync(CANON_DIR, { recursive: true });
69
- let profiles = {};
70
- try {
71
- profiles = JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
70
+ if (!result.apiKey || !result.agentId || !result.agentName) {
71
+ console.error('Approval completed but Canon did not return a usable API key. Run this command again to resume key pickup.');
72
+ process.exit(1);
72
73
  }
73
- catch {
74
- // File doesn't exist yet
75
- }
76
- profiles[profileName] = {
74
+ upsertAgentProfile(profileName, {
77
75
  apiKey: result.apiKey,
78
76
  agentId: result.agentId,
79
77
  agentName: result.agentName,
80
78
  registeredAt: new Date().toISOString(),
81
- };
82
- writeFileSync(AGENTS_PATH, JSON.stringify(profiles, null, 2));
79
+ clientType: 'claude-code',
80
+ ...(typeof values['base-url'] === 'string' ? { baseUrl: values['base-url'] } : {}),
81
+ });
82
+ if (result.requestId) {
83
+ await ackRegistrationApproval(values['base-url'], result.requestId, result.pollToken);
84
+ }
85
+ clearPendingRegistration(profileName);
83
86
  console.log(`Approved! Agent: ${result.agentName} (${result.agentId})`);
84
87
  console.log(`Saved as profile "${profileName}" in ~/.canon/agents.json`);
85
- console.log('Restart Claude Code to connect.');
88
+ console.log('Start it with: CANON_AGENT=' + profileName + ' canon-claude --cwd /path/to/project');
89
+ console.log('Keep that terminal open. Closing it takes this local agent offline.');
86
90
  break;
87
91
  }
88
92
  case 'rejected':
package/dist/server.js CHANGED
@@ -10,7 +10,7 @@ import { setDefaultResultOrder } from 'node:dns';
10
10
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
11
11
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
12
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
13
- import { CanonClient, CanonStream, ApprovalManager, loadProfiles, isProfileLocked, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, writeSessionState, clearSessionState, } from '@canonmsg/core';
13
+ import { CanonClient, CanonStream, ApprovalManager, buildLocalRuntimeId, getActiveProfileLock, markLocalRuntimeStopped, loadProfiles, isProfileLocked, resolveCanonAgent, getActiveProfile, releaseLock, upsertLocalRuntimeEntry, initRTDBAuth, writeSessionState, clearSessionState, } from '@canonmsg/core';
14
14
  import { ApprovalHttpServer } from './approval-server.js';
15
15
  import { runCli } from './cli-entry.js';
16
16
  // ── MCP server ─────────────────────────────────────────────────────────
@@ -525,10 +525,14 @@ async function handleInboundMessage(payload) {
525
525
  async function startChannel() {
526
526
  let apiKey;
527
527
  let profile;
528
+ let baseUrl;
529
+ let profileAgentName;
528
530
  try {
529
- const resolved = resolveCanonAgent({ logPrefix: '[canon]' });
531
+ const resolved = resolveCanonAgent({ logPrefix: '[canon]', expectedClientType: 'claude-code' });
530
532
  apiKey = resolved.apiKey;
531
533
  profile = resolved.profile;
534
+ baseUrl = resolved.baseUrl;
535
+ profileAgentName = resolved.agentName;
532
536
  }
533
537
  catch (err) {
534
538
  console.error(err instanceof Error ? err.message : err);
@@ -537,7 +541,7 @@ async function startChannel() {
537
541
  if (profile) {
538
542
  console.error(`[canon] Using agent profile: ${profile}`);
539
543
  }
540
- client = new CanonClient(apiKey);
544
+ client = new CanonClient(apiKey, baseUrl);
541
545
  // Get agent identity — try /agents/me first, fall back to /agents/auth-token
542
546
  let agentId;
543
547
  try {
@@ -560,6 +564,27 @@ async function startChannel() {
560
564
  }
561
565
  // Initialize RTDB auth for session state reporting
562
566
  initRTDBAuth(client);
567
+ const runtimeId = buildLocalRuntimeId({
568
+ runtime: 'claude-code',
569
+ profile,
570
+ cwd: process.cwd(),
571
+ launchCommand: ['canon-channel-server'],
572
+ });
573
+ upsertLocalRuntimeEntry({
574
+ id: runtimeId,
575
+ runtime: 'claude-code',
576
+ profile,
577
+ agentId,
578
+ agentName: agentContext?.displayName ?? profileAgentName,
579
+ cwd: process.cwd(),
580
+ launchCommand: ['canon-channel-server'],
581
+ pid: process.pid,
582
+ status: 'embedded',
583
+ reviveCapability: 'embedded',
584
+ surfaceMode: 'channel',
585
+ lastStartedAt: new Date().toISOString(),
586
+ lastHeartbeatAt: new Date().toISOString(),
587
+ });
563
588
  // Populate conversation cache
564
589
  try {
565
590
  const convos = await client.getConversations();
@@ -627,13 +652,22 @@ export async function main() {
627
652
  approvalManager?.dispose();
628
653
  approvalServer?.stop().catch(() => { });
629
654
  stream?.stop();
655
+ const runtimeId = buildLocalRuntimeId({
656
+ runtime: 'claude-code',
657
+ profile: getActiveProfile(),
658
+ cwd: process.cwd(),
659
+ launchCommand: ['canon-channel-server'],
660
+ });
661
+ markLocalRuntimeStopped(runtimeId);
630
662
  const ap = getActiveProfile();
631
663
  if (ap)
632
664
  releaseLock(ap);
665
+ getActiveProfileLock()?.release();
633
666
  process.exit(0);
634
667
  };
635
668
  process.on('SIGINT', shutdown);
636
669
  process.on('SIGTERM', shutdown);
670
+ process.on('SIGHUP', shutdown);
637
671
  }
638
672
  runCli(import.meta.url, main, (err) => {
639
673
  console.error('[canon] Fatal error:', err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Canon channel plugin for Claude Code — messaging where AI agents are first-class citizens",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,8 +31,8 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "^0.2.101",
34
- "@canonmsg/agent-sdk": "^0.9.2",
35
- "@canonmsg/core": "^0.10.0",
34
+ "@canonmsg/agent-sdk": "^0.10.2",
35
+ "@canonmsg/core": "^0.14.0",
36
36
  "@modelcontextprotocol/sdk": "^1.12.0"
37
37
  },
38
38
  "engines": {
@@ -1,134 +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/codex-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/claude-code-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 and other
58
- * binary attachments are referenced by short placeholders — their actual
59
- * bytes are delivered to the host as native vision/media inputs (Anthropic
60
- * image blocks, Codex `-i` file args). URLs are intentionally *not* inlined,
61
- * since the harness never needs to refetch and earlier `[Image: <url>]`
62
- * inlining caused vision models to see a string about an image instead of
63
- * the image itself.
64
- *
65
- * `materialized` may be passed so non-image attachments can reference a
66
- * local path the agent can Read. Without it we fall back to an unadorned
67
- * placeholder; the vision path still works because image blocks carry the
68
- * bytes directly.
69
- */
70
- export declare function renderCanonHostInboundContent(message: HostInboundMessage, materialized?: ReadonlyArray<{
71
- kind: 'image' | 'audio' | 'file';
72
- path: string;
73
- fileName?: string;
74
- durationMs?: number;
75
- index: number;
76
- }>): string;
77
- export declare function buildHydratedInboundContext(input: {
78
- agentId: string;
79
- conversation: CanonConversation | null;
80
- page?: CanonMessagesPage | null;
81
- message: HostInboundMessage;
82
- senderName: string;
83
- isOwner: boolean;
84
- }): {
85
- participantContext: HostInboundParticipantContext;
86
- behavior?: ResolvedAgentBehaviorPolicy | null;
87
- workSessions: NonNullable<MessageCreatedPayload['workSessions']>;
88
- hydratedFromPage: boolean;
89
- };
90
- export declare function publishHostAgentRuntime(agentId: string, clientType: AgentClientType, runtime: AgentRuntime): Promise<void>;
91
- export declare function publishHostSessionSnapshots(input: {
92
- conversationIds: string[];
93
- agentId: string;
94
- clientType: AgentClientType;
95
- runtime: AgentRuntime;
96
- workspaceOptions: HostWorkspaceResolverOption[];
97
- defaultCwd: string;
98
- liveSessionConfigByConversation?: ReadonlyMap<string, {
99
- model?: string;
100
- permissionMode?: string;
101
- effort?: string;
102
- runtimeControlValues?: Record<string, string>;
103
- workspaceId?: string;
104
- executionMode?: SessionWorkspaceConfig['executionMode'];
105
- executionBranch?: string | null;
106
- }>;
107
- }): Promise<void>;
108
- export declare function readHostSessionConfig<TExtra extends string = never>(raw: unknown, extraStringFields?: readonly TExtra[]): (SessionWorkspaceConfig & Partial<Record<TExtra, string>> & {
109
- runtimeControlValues?: Record<string, string>;
110
- }) | null;
111
- export declare function loadHostSessionConfig<TExtra extends string = never>(input: {
112
- conversationId: string;
113
- agentId: string;
114
- extraStringFields?: readonly TExtra[];
115
- }): Promise<(SessionWorkspaceConfig & Partial<Record<TExtra, string>> & {
116
- runtimeControlValues?: Record<string, string>;
117
- }) | null>;
118
- export declare function resolveHostWorkspaceCwd(input: {
119
- workspaceOptions: HostWorkspaceResolverOption[];
120
- config: {
121
- workspaceId?: string;
122
- retiredWorkspaceConfig?: boolean;
123
- } | null;
124
- defaultCwd: string;
125
- }): string;
126
- export declare function createConversationMetadataLoader(input: {
127
- client: CanonClient;
128
- conversationCache: Map<string, CanonConversation>;
129
- cacheTtlMs?: number;
130
- }): {
131
- refreshConversationCache(force?: boolean): Promise<void>;
132
- getConversationMeta(conversationId: string): Promise<CanonConversation | null>;
133
- };
134
- export {};
@@ -1,266 +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/codex-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/claude-code-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 and other
43
- * binary attachments are referenced by short placeholders — their actual
44
- * bytes are delivered to the host as native vision/media inputs (Anthropic
45
- * image blocks, Codex `-i` file args). URLs are intentionally *not* inlined,
46
- * since the harness never needs to refetch and earlier `[Image: <url>]`
47
- * inlining caused vision models to see a string about an image instead of
48
- * the image itself.
49
- *
50
- * `materialized` may be passed so non-image attachments can reference a
51
- * local path the agent can Read. Without it we fall back to an unadorned
52
- * placeholder; the vision path still works because image blocks carry the
53
- * bytes directly.
54
- */
55
- export function renderCanonHostInboundContent(message, materialized) {
56
- const body = message.text || '';
57
- const placeholders = [];
58
- const attachments = message.attachments ?? [];
59
- for (let i = 0; i < attachments.length; i += 1) {
60
- const att = attachments[i];
61
- const mat = materialized?.find((m) => m.index === i) ?? null;
62
- placeholders.push(describeAttachment(att, mat));
63
- }
64
- if (message.contentType === 'contact_card' && message.contactCard) {
65
- placeholders.push(describeContactCard(message.contactCard));
66
- }
67
- const rendered = [...placeholders, body].filter(Boolean).join('\n');
68
- return rendered || '[Empty message]';
69
- }
70
- function describeContactCard(card) {
71
- const parts = [`${card.userType} · userId: ${card.userId}`];
72
- if (card.accessLevel)
73
- parts.push(`access: ${card.accessLevel}`);
74
- if (card.ownerName)
75
- parts.push(`owner: ${card.ownerName}`);
76
- if (card.about)
77
- parts.push(`about: ${card.about}`);
78
- const identity = `📇 Contact card: "${card.displayName}" (${parts.join(' · ')}).`;
79
- const missingCapabilities = [
80
- !HOST_ADMISSION_ACTION_CAPABILITIES.canStartDirectConversation
81
- ? 'start a direct conversation'
82
- : null,
83
- !HOST_ADMISSION_ACTION_CAPABILITIES.canSendContactRequest
84
- ? 'send a contact request'
85
- : null,
86
- !HOST_ADMISSION_ACTION_CAPABILITIES.canApprovePendingContactRequests
87
- ? 'approve pending requests'
88
- : null,
89
- !HOST_ADMISSION_ACTION_CAPABILITIES.canRejectPendingContactRequests
90
- ? 'reject pending requests'
91
- : null,
92
- ].filter(Boolean).join(', ');
93
- 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}.`;
94
- return `${identity}\n${hint}`;
95
- }
96
- function describeAttachment(attachment, materialized) {
97
- if (attachment.kind === 'image') {
98
- return '[Image attached]';
99
- }
100
- if (attachment.kind === 'audio') {
101
- const durationMs = materialized?.durationMs ?? attachment.durationMs;
102
- const duration = durationMs ? ` (${Math.round(durationMs / 1000)}s)` : '';
103
- const ref = materialized?.path ? ` ${materialized.path}` : '';
104
- return `[Voice message${duration}${ref}]`;
105
- }
106
- // file
107
- const label = materialized?.fileName ?? attachment.fileName ?? 'File';
108
- const ref = materialized?.path ? ` ${materialized.path}` : '';
109
- return `[File: ${label}${ref}]`;
110
- }
111
- export function buildHydratedInboundContext(input) {
112
- const history = buildParticipationHistorySnapshot(input.page?.messages ?? [], input.agentId);
113
- return {
114
- participantContext: {
115
- conversationType: input.conversation?.type ?? 'unknown',
116
- memberCount: input.conversation?.memberIds?.length ?? null,
117
- senderType: input.message.senderType ?? 'human',
118
- senderName: input.senderName,
119
- isOwner: input.isOwner,
120
- mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(input.agentId),
121
- recentSenderTypes: history.recentSenderTypes,
122
- recentHumanCount: history.recentHumanCount,
123
- recentAgentCount: history.recentAgentCount,
124
- consecutiveAgentTurns: history.consecutiveAgentTurns,
125
- currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
126
- },
127
- behavior: input.page?.behavior ?? input.conversation?.behavior,
128
- workSessions: input.page?.workSessions ?? [],
129
- hydratedFromPage: input.page != null,
130
- };
131
- }
132
- export async function publishHostAgentRuntime(agentId, clientType, runtime) {
133
- await rtdbWrite(`/agent-runtime/${agentId}`, {
134
- clientType,
135
- hostMode: true,
136
- ...runtime,
137
- updatedAt: { '.sv': 'timestamp' },
138
- });
139
- }
140
- export async function publishHostSessionSnapshots(input) {
141
- if (input.conversationIds.length === 0) {
142
- return;
143
- }
144
- await Promise.all(input.conversationIds.map(async (conversationId) => {
145
- const persistedConfig = await loadHostSessionConfig({
146
- conversationId,
147
- agentId: input.agentId,
148
- extraStringFields: ['permissionMode', 'effort'],
149
- });
150
- const liveConfig = input.liveSessionConfigByConversation?.get(conversationId) ?? null;
151
- const mergedConfig = {
152
- ...(persistedConfig ?? {}),
153
- ...(liveConfig ?? {}),
154
- };
155
- const snapshot = buildAgentSessionSnapshot({
156
- conversationId,
157
- agentId: input.agentId,
158
- runtime: {
159
- ...input.runtime,
160
- clientType: input.clientType,
161
- hostMode: true,
162
- },
163
- sessionConfig: {
164
- ...(mergedConfig.model ? { model: mergedConfig.model } : {}),
165
- ...(mergedConfig.permissionMode ? { permissionMode: mergedConfig.permissionMode } : {}),
166
- ...(mergedConfig.effort ? { effort: mergedConfig.effort } : {}),
167
- ...(mergedConfig.runtimeControlValues
168
- ? { runtimeControlValues: mergedConfig.runtimeControlValues }
169
- : {}),
170
- ...(mergedConfig.workspaceId ? { workspaceId: mergedConfig.workspaceId } : {}),
171
- ...(mergedConfig.executionMode ? { executionMode: mergedConfig.executionMode } : {}),
172
- },
173
- lastHeartbeatAt: undefined,
174
- });
175
- let executionBranch = liveConfig?.executionBranch ?? null;
176
- if (!executionBranch && snapshot.executionMode === 'worktree' && snapshot.workspaceId) {
177
- const workspace = input.workspaceOptions.find((option) => option.id === snapshot.workspaceId);
178
- if (workspace) {
179
- executionBranch = buildConversationWorktreeSpec({
180
- agentId: input.agentId,
181
- conversationId,
182
- workspaceCwd: workspace.cwd,
183
- }).branch;
184
- }
185
- }
186
- return patchAgentSessionSnapshot(conversationId, input.agentId, {
187
- clientType: input.clientType,
188
- hostMode: true,
189
- model: snapshot.model ?? null,
190
- permissionMode: snapshot.permissionMode ?? null,
191
- effort: snapshot.effort ?? null,
192
- runtimeControlValues: snapshot.runtimeControlValues ?? null,
193
- workspaceId: snapshot.workspaceId ?? null,
194
- executionMode: snapshot.executionMode ?? null,
195
- executionBranch,
196
- modelOptions: snapshot.modelOptions,
197
- permissionModeOptions: snapshot.permissionModeOptions,
198
- workspaceOptions: snapshot.workspaceOptions,
199
- availableExecutionModes: snapshot.availableExecutionModes,
200
- lastHeartbeatAt: { '.sv': 'timestamp' },
201
- });
202
- }));
203
- }
204
- export function readHostSessionConfig(raw, extraStringFields = []) {
205
- const baseConfig = readSessionWorkspaceConfig(raw);
206
- if (!raw || typeof raw !== 'object') {
207
- return baseConfig;
208
- }
209
- const data = raw;
210
- const extraConfig = Object.fromEntries(extraStringFields.flatMap((field) => {
211
- const value = normalizeOptionalString(data[field]);
212
- return value ? [[field, value]] : [];
213
- }));
214
- const runtimeControlValues = Object.fromEntries(Object.entries(data.runtimeControlValues && typeof data.runtimeControlValues === 'object'
215
- ? data.runtimeControlValues
216
- : {}).flatMap(([key, value]) => {
217
- const normalizedValue = normalizeOptionalString(value);
218
- return normalizedValue ? [[key, normalizedValue]] : [];
219
- }));
220
- return {
221
- ...(baseConfig ?? {}),
222
- ...extraConfig,
223
- ...(Object.keys(runtimeControlValues).length > 0 ? { runtimeControlValues } : {}),
224
- };
225
- }
226
- export async function loadHostSessionConfig(input) {
227
- const raw = await rtdbRead(`/session-config/${input.conversationId}/${input.agentId}`);
228
- return readHostSessionConfig(raw, input.extraStringFields);
229
- }
230
- export function resolveHostWorkspaceCwd(input) {
231
- return resolveConfiguredWorkspaceCwd(input);
232
- }
233
- export function createConversationMetadataLoader(input) {
234
- const cacheTtlMs = input.cacheTtlMs ?? 10_000;
235
- let conversationCacheLoadedAt = 0;
236
- async function refreshConversationCache(force = false) {
237
- if (!force
238
- && input.conversationCache.size > 0
239
- && Date.now() - conversationCacheLoadedAt < cacheTtlMs) {
240
- return;
241
- }
242
- const conversations = await input.client.getConversations();
243
- input.conversationCache.clear();
244
- for (const conversation of conversations) {
245
- input.conversationCache.set(conversation.id, conversation);
246
- }
247
- conversationCacheLoadedAt = Date.now();
248
- }
249
- async function getConversationMeta(conversationId) {
250
- try {
251
- await refreshConversationCache();
252
- const cached = input.conversationCache.get(conversationId);
253
- if (cached)
254
- return cached;
255
- await refreshConversationCache(true);
256
- return input.conversationCache.get(conversationId) ?? null;
257
- }
258
- catch {
259
- return input.conversationCache.get(conversationId) ?? null;
260
- }
261
- }
262
- return {
263
- refreshConversationCache,
264
- getConversationMeta,
265
- };
266
- }