@canonmsg/claude-code-plugin 0.15.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/host.js CHANGED
@@ -24,11 +24,17 @@ import { parseArgs } from 'node:util';
24
24
  import { randomUUID } from 'node:crypto';
25
25
  import { execFileSync } from 'node:child_process';
26
26
  import { existsSync } from 'node:fs';
27
- import { query, } from '@anthropic-ai/claude-agent-sdk';
28
- import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
29
- import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPlanApprovalRequest, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, rtdbRead, rtdbWrite, sendMessageWithRetry, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCanonTurnBriefPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
27
+ import { readFile, stat } from 'node:fs/promises';
28
+ import { basename, isAbsolute, join } from 'node:path';
29
+ import { createSdkMcpServer, query, tool, } from '@anthropic-ai/claude-agent-sdk';
30
+ import { z } from 'zod';
31
+ import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
32
+ import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPlanApprovalRequest, resolveQuestionAllowOther, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, sendMessageWithRetry, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCanonTurnBriefPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
30
33
  import { decideAutoReply, } from './inbound-policy.js';
31
34
  import { runCli } from './cli-entry.js';
35
+ import { OUTBOX_MAX_FILE_BYTES, buildOutboxContextLine, ensureOutboxDir, flushOutbox, resolveOutboxDir, } from './outbox.js';
36
+ import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
37
+ import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
32
38
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
33
39
  import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
34
40
  function parseRuntimeVisibilityPreset(value) {
@@ -85,6 +91,38 @@ let runtimeMetadata = {
85
91
  loadedAt: 0,
86
92
  };
87
93
  let runtimeMetadataRefresh = null;
94
+ /** Bound on native slash commands exposed to the composer palette. */
95
+ const MAX_NATIVE_SLASH_COMMANDS = 50;
96
+ /**
97
+ * Claude Code's own slash commands (built-ins, plugins, project .claude/
98
+ * commands) are executable when sent as prompt text, so the runtime's command
99
+ * inventory is exposed as composer slash entries that pass the raw slash text
100
+ * through. Canon-injected aliases keep precedence over native names.
101
+ */
102
+ export function buildNativeSlashCommandDescriptors(native, reservedAliases) {
103
+ const seen = new Set(reservedAliases);
104
+ const descriptors = [];
105
+ for (const command of native.slice(0, MAX_NATIVE_SLASH_COMMANDS)) {
106
+ const name = String(command.name ?? '').replace(/^\//, '').trim();
107
+ if (!name || !/^[A-Za-z0-9:_-]+$/.test(name) || seen.has(name))
108
+ continue;
109
+ seen.add(name);
110
+ descriptors.push({
111
+ id: `claude-native-${name}`,
112
+ label: `/${name}`,
113
+ description: command.description
114
+ ?? ('argumentHint' in command ? command.argumentHint : undefined)
115
+ ?? 'Claude Code command (runs in the runtime).',
116
+ aliases: [name],
117
+ category: 'runtime',
118
+ placements: ['composer_slash', 'command_palette'],
119
+ availability: ['always'],
120
+ trailingTextBehavior: 'send_as_prompt',
121
+ dispatch: { kind: 'text_passthrough', template: `/${name}` },
122
+ });
123
+ }
124
+ return descriptors;
125
+ }
88
126
  function buildClaudeRuntimeDescriptor(input) {
89
127
  const commands = [
90
128
  {
@@ -166,6 +204,7 @@ function buildClaudeRuntimeDescriptor(input) {
166
204
  RUNTIME_STOP_ACTION,
167
205
  RUNTIME_STOP_AND_DROP_ACTION,
168
206
  ];
207
+ commands.push(...buildNativeSlashCommandDescriptors(runtimeMetadata.commands, new Set(commands.flatMap((command) => [command.id, ...(command.aliases ?? [])]))));
169
208
  return buildFirstPartyCodingRuntimeDescriptor({
170
209
  clientType: 'claude-code',
171
210
  models: input.models,
@@ -174,6 +213,7 @@ function buildClaudeRuntimeDescriptor(input) {
174
213
  executionModes: input.executionModes,
175
214
  permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
176
215
  defaultPermissionMode: 'default',
216
+ effortOptions: [...CLAUDE_EFFORT_OPTIONS],
177
217
  streamingTextMode: 'delta',
178
218
  turnModes: [
179
219
  {
@@ -422,12 +462,17 @@ function mapClaudeQuestions(value) {
422
462
  const optionValue = typeof optionRecord.value === 'string' && optionRecord.value.trim()
423
463
  ? optionRecord.value.trim().slice(0, 120)
424
464
  : label;
465
+ const description = typeof optionRecord.description === 'string' && optionRecord.description.trim()
466
+ ? optionRecord.description.trim().slice(0, 240)
467
+ : undefined;
468
+ const preview = typeof optionRecord.preview === 'string' && optionRecord.preview.trim()
469
+ ? optionRecord.preview.trim().slice(0, 4000)
470
+ : undefined;
425
471
  return [{
426
472
  label,
427
473
  value: optionValue,
428
- ...(typeof optionRecord.description === 'string' && optionRecord.description.trim()
429
- ? { description: optionRecord.description.trim().slice(0, 240) }
430
- : {}),
474
+ ...(description ? { description } : {}),
475
+ ...(preview ? { preview } : {}),
431
476
  }];
432
477
  });
433
478
  return [{
@@ -435,7 +480,12 @@ function mapClaudeQuestions(value) {
435
480
  question,
436
481
  ...(header ? { header } : {}),
437
482
  ...(choices.length ? { choices } : {}),
438
- ...(record.isOther === true || record.allowOther === true ? { allowOther: true } : {}),
483
+ // Mac-app parity: AskUserQuestion always offers a free-text "Other" escape
484
+ // hatch (the SDK adds it client-side, not via the tool schema). Default it
485
+ // on for choice questions unless the agent explicitly opted out.
486
+ ...(resolveQuestionAllowOther(choices.length > 0, record.allowOther ?? record.isOther)
487
+ ? { allowOther: true }
488
+ : {}),
439
489
  ...(record.isSecret === true ? { isSecret: true } : {}),
440
490
  ...(record.multiSelect === true ? { multiSelect: true } : {}),
441
491
  }];
@@ -449,6 +499,17 @@ function flattenRuntimeInputAnswers(answers) {
449
499
  }
450
500
  return flattened;
451
501
  }
502
+ /** Pre-image reader for approval diff synthesis: missing file → null (Write creates it). */
503
+ async function readApprovalDiffPreImage(filePath) {
504
+ try {
505
+ return await readFile(filePath, 'utf8');
506
+ }
507
+ catch (error) {
508
+ if (error?.code === 'ENOENT')
509
+ return null;
510
+ throw error;
511
+ }
512
+ }
452
513
  function mapClaudeMcpStatus(status) {
453
514
  switch (status) {
454
515
  case 'connected':
@@ -518,6 +579,12 @@ function buildClaudeRuntimeInfo(input) {
518
579
  value: 'Runtime policy',
519
580
  tier: 'detail',
520
581
  },
582
+ {
583
+ id: 'mediaOut',
584
+ label: 'Media out',
585
+ value: 'Turn-end outbox + canon_send_file',
586
+ tier: 'detail',
587
+ },
521
588
  ].filter((item) => Boolean(item));
522
589
  return {
523
590
  descriptor,
@@ -604,10 +671,6 @@ const CLAUDE_RUNTIME_CAPABILITIES = {
604
671
  supportsRequiresAction: true,
605
672
  supportsNonFinalPermanentMessages: false,
606
673
  };
607
- function controlPollDelayMs(hasActiveWork) {
608
- const base = hasActiveWork ? CONTROL_POLL_MS : IDLE_CONTROL_POLL_MS;
609
- return base + Math.floor(Math.random() * CONTROL_POLL_JITTER_MS);
610
- }
611
674
  function buildCanonPrompt(input) {
612
675
  const sessionContextLines = input.session ? buildClaudeSessionContextLines(input.session) : undefined;
613
676
  return renderCanonTurnBriefPrompt(buildCanonTurnContextV2({
@@ -643,6 +706,7 @@ function buildClaudeSessionContextLines(session) {
643
706
  `Working directory: ${session.cwd}`,
644
707
  session.environment.branch ? `Git branch: ${session.environment.branch}` : null,
645
708
  session.environment.worktreePath ? `Worktree: ${session.environment.worktreePath}` : null,
709
+ `${buildOutboxContextLine(session.cwd)} To send one file immediately mid-turn, use the canon_send_file tool instead.`,
646
710
  ].filter((line) => Boolean(line));
647
711
  }
648
712
  function renderInboundContent(message, materialized) {
@@ -785,10 +849,67 @@ function createSession(conversationId, environment, agentId, client, typingSigna
785
849
  }
786
850
  return { status: 'timeout' };
787
851
  }
852
+ function buildOutboxMediaMetadata() {
853
+ return {
854
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
855
+ // Media is permanent conversation content (promoted like a final reply)
856
+ // but must never re-trigger other agents — the host's own final text
857
+ // message stays the only turn-complete trigger.
858
+ turnSemantics: 'turn_complete',
859
+ replyBehavior: 'suppress_auto_reply',
860
+ };
861
+ }
862
+ function sendOutboxMediaFile(filePath, caption = '') {
863
+ return sendMediaFileMessage(client, conversationId, filePath, caption, {
864
+ ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
865
+ metadata: buildOutboxMediaMetadata(),
866
+ });
867
+ }
868
+ // Immediate explicit media-out channel: the agent calls this tool with a
869
+ // path, the host uploads + sends right away. Permission flows through
870
+ // canUseTool like every other MCP tool (owner approval in default mode).
871
+ const canonMediaServer = createSdkMcpServer({
872
+ name: 'canon',
873
+ tools: [
874
+ tool('canon_send_file', 'Send a local file (screenshot, plot, artifact) to the current Canon conversation immediately as a media attachment.', {
875
+ path: z.string().describe('Path of the file to send; relative paths resolve against the session working directory.'),
876
+ caption: z.string().optional().describe('Optional caption shown with the attachment.'),
877
+ }, async (args) => {
878
+ try {
879
+ const filePath = isAbsolute(args.path) ? args.path : join(cwd, args.path);
880
+ const info = await stat(filePath);
881
+ if (!info.isFile()) {
882
+ throw new Error(`${filePath} is not a regular file`);
883
+ }
884
+ if (info.size > OUTBOX_MAX_FILE_BYTES) {
885
+ throw new Error(`File is ${info.size} bytes; the Canon media limit is ${OUTBOX_MAX_FILE_BYTES} bytes`);
886
+ }
887
+ const { messageId } = await sendOutboxMediaFile(filePath, args.caption ?? '');
888
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] canon_send_file sent ${basename(filePath)} (${messageId})`);
889
+ return {
890
+ content: [{
891
+ type: 'text',
892
+ text: `Sent ${basename(filePath)} to the Canon conversation (message ${messageId}).`,
893
+ }],
894
+ };
895
+ }
896
+ catch (error) {
897
+ return {
898
+ content: [{
899
+ type: 'text',
900
+ text: `canon_send_file failed: ${error instanceof Error ? error.message : String(error)}`,
901
+ }],
902
+ isError: true,
903
+ };
904
+ }
905
+ }),
906
+ ],
907
+ });
788
908
  const claudeCliPath = resolveClaudeCliPath();
789
909
  const queryOptions = {
790
910
  systemPrompt: { type: 'preset', preset: 'claude_code' },
791
911
  tools: { type: 'preset', preset: 'claude_code' },
912
+ mcpServers: { canon: canonMediaServer },
792
913
  permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
793
914
  allowDangerouslySkipPermissions: true,
794
915
  cwd,
@@ -872,13 +993,16 @@ function createSession(conversationId, environment, agentId, client, typingSigna
872
993
  })
873
994
  .filter((entry) => Boolean(entry))
874
995
  : [];
875
- const planApproval = buildPlanApprovalRequest(planId, 'Plan ready for review.', allowedPrompts.length > 0 ? {
876
- summary: 'Claude has prepared a plan and is asking permission to continue.',
877
- ...(cardResponseUser ?? {}),
878
- allowedPrompts,
879
- } : {
996
+ // Surface the actual plan markdown on the card so the owner reviews
997
+ // the plan inline (Mac-app parity), not just a generic summary.
998
+ const planBody = typeof input.plan === 'string' && input.plan.trim()
999
+ ? input.plan.trim()
1000
+ : undefined;
1001
+ const planApproval = buildPlanApprovalRequest(planId, 'Plan ready for review.', {
880
1002
  summary: 'Claude has prepared a plan and is asking permission to continue.',
1003
+ ...(planBody ? { body: planBody } : {}),
881
1004
  ...(cardResponseUser ?? {}),
1005
+ ...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
882
1006
  });
883
1007
  await sendMessageWithRetry(client, conversationId, planApproval.text, {
884
1008
  messageId: `claude-plan-${planId}`,
@@ -894,11 +1018,57 @@ function createSession(conversationId, environment, agentId, client, typingSigna
894
1018
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
895
1019
  const reply = await waitForReply('plan_approval_reply', planId);
896
1020
  if (reply.decision === 'approve') {
1021
+ // Honor opt-in selective grants: when the card sent an explicit
1022
+ // granted subset, pre-approve those tools as session rules so they
1023
+ // do not re-prompt. Owner-turn only; absent grants preserve the
1024
+ // legacy behavior (each tool still prompts individually).
1025
+ const isOwnerTurn = session.activeInput?.kind === 'canon'
1026
+ ? session.activeInput.isOwnerTurn === true
1027
+ : true;
1028
+ if (isOwnerTurn && config.approvalManager && Array.isArray(reply.grantedPrompts)) {
1029
+ // Only pre-approve tools the plan actually offered, and bound the
1030
+ // count/length to match parseSessionRule's caps — grantedPrompts
1031
+ // is untrusted wire metadata, not the validated approval path.
1032
+ const offeredTools = new Set(allowedPrompts.map((entry) => entry.tool));
1033
+ const grantedTools = new Set();
1034
+ for (const grant of reply.grantedPrompts) {
1035
+ if (grantedTools.size >= 32)
1036
+ break;
1037
+ const tool = typeof grant?.tool === 'string' ? grant.tool.trim() : '';
1038
+ if (tool && tool !== 'Tool' && tool.length <= 128 && offeredTools.has(tool)) {
1039
+ grantedTools.add(tool);
1040
+ }
1041
+ }
1042
+ for (const tool of grantedTools) {
1043
+ config.approvalManager.addSessionRule(conversationId, {
1044
+ type: 'approve-tool',
1045
+ toolPattern: tool.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
1046
+ });
1047
+ }
1048
+ if (grantedTools.size > 0) {
1049
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan granted ${grantedTools.size} tool(s) for session`);
1050
+ }
1051
+ }
1052
+ // The SDK leaves plan mode when we allow ExitPlanMode, but it does
1053
+ // not notify the host, so reset the mirrored permission mode here so
1054
+ // the composer turn-mode toggle and permission chip stop reporting
1055
+ // 'plan'. Approve-only: on reject/revise the SDK stays in plan mode.
1056
+ if (session.state.permissionMode === 'plan') {
1057
+ session.state.permissionMode = 'default';
1058
+ writeState();
1059
+ }
897
1060
  return { behavior: 'allow' };
898
1061
  }
1062
+ if (reply.decision === 'reject') {
1063
+ return {
1064
+ behavior: 'deny',
1065
+ message: reply.feedback?.trim()
1066
+ || 'Plan declined — keep planning and wait for guidance before proceeding.',
1067
+ };
1068
+ }
899
1069
  return {
900
1070
  behavior: 'deny',
901
- message: reply.feedback || 'User requested changes',
1071
+ message: reply.feedback?.trim() || 'User requested changes to the plan.',
902
1072
  };
903
1073
  }
904
1074
  catch {
@@ -918,6 +1088,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
918
1088
  approvalManager: config.approvalManager,
919
1089
  canRequestApproval: config.canRequestApproval,
920
1090
  allowedNonOwnerTools: allowedNonOwnerClaudeTools,
1091
+ // File-change preview for Edit/Write-class approvals: canUseTool fires
1092
+ // before the mutation lands, so read the pre-image from disk and splice
1093
+ // the edit in memory. Best-effort — failure logs and omits the diff.
1094
+ buildApprovalDiff: async () => {
1095
+ try {
1096
+ return await synthesizeClaudeApprovalDiff(toolName, input, {
1097
+ cwd,
1098
+ readFile: readApprovalDiffPreImage,
1099
+ });
1100
+ }
1101
+ catch (error) {
1102
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Approval diff synthesis failed (${toolName}): ${error instanceof Error ? error.message : String(error)}`);
1103
+ return undefined;
1104
+ }
1105
+ },
921
1106
  });
922
1107
  },
923
1108
  };
@@ -1115,6 +1300,34 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1115
1300
  return false;
1116
1301
  }
1117
1302
  }
1303
+ /**
1304
+ * Turn-end media outbox flush (see ./outbox.ts for the convention). Never
1305
+ * throws: upload failures are logged and the files stay in place for a
1306
+ * later turn.
1307
+ */
1308
+ async function flushSessionOutbox() {
1309
+ try {
1310
+ const result = await flushOutbox({
1311
+ outboxDir: resolveOutboxDir(cwd),
1312
+ send: (file) => sendOutboxMediaFile(file.path),
1313
+ });
1314
+ for (const entry of result.sent) {
1315
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent outbox file ${entry.file.fileName} (${entry.messageId})`
1316
+ + (entry.removeFailed ? ' — removal failed; it may resend next turn' : ''));
1317
+ }
1318
+ for (const failure of result.failed) {
1319
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Outbox upload failed for ${failure.file.fileName}; left in place: ${failure.error}`);
1320
+ }
1321
+ for (const skipped of result.skipped) {
1322
+ if (skipped.reason === 'too-large' || skipped.reason === 'file-cap') {
1323
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Outbox skipped ${skipped.fileName} (${skipped.reason}); left in place`);
1324
+ }
1325
+ }
1326
+ }
1327
+ catch (error) {
1328
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Outbox flush failed:`, error instanceof Error ? error.message : error);
1329
+ }
1330
+ }
1118
1331
  function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
1119
1332
  if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
1120
1333
  return;
@@ -1401,6 +1614,12 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1401
1614
  session.sdkSessionId = resultSessionId;
1402
1615
  const completedInput = session.activeInput;
1403
1616
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
1617
+ // Turn-end outbox flush — media lands before the final text reply.
1618
+ // Interrupted turns keep their files for the next completed turn.
1619
+ if (completedInput?.kind === 'canon'
1620
+ && !session.interruptedTurnKeys.has(completedInput.turnKey)) {
1621
+ await flushSessionOutbox();
1622
+ }
1404
1623
  const resultText = typeof msg.result === 'string'
1405
1624
  ? msg.result.trim()
1406
1625
  : '';
@@ -1462,6 +1681,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1462
1681
  // Start processing loop and initialize session
1463
1682
  const init = async () => {
1464
1683
  processMessages().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] processMessages crashed:`, err));
1684
+ ensureOutboxDir(cwd).catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to create media outbox:`, err));
1465
1685
  await q.initializationResult();
1466
1686
  if (config.model) {
1467
1687
  try {
@@ -1570,7 +1790,7 @@ export async function main() {
1570
1790
  console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
1571
1791
  // ── Canon API client + RTDB auth ──
1572
1792
  const client = new CanonClient(apiKey, baseUrl);
1573
- initRTDBAuth(client);
1793
+ const rtdb = initRTDBAuth(client);
1574
1794
  const typingSignals = createTypingStatusPublisher({
1575
1795
  setTyping: (conversationId, typing, status) => status
1576
1796
  ? client.setTyping(conversationId, typing, status)
@@ -1711,6 +1931,21 @@ export async function main() {
1711
1931
  }).catch((error) => {
1712
1932
  console.error('[canon-host] Failed to publish session snapshots:', error);
1713
1933
  });
1934
+ // Rebuild so the descriptor picks up native slash commands from the
1935
+ // metadata refresh above — the initial descriptor is built before the
1936
+ // command inventory has loaded.
1937
+ if (runtimeDescriptor.runtimeDescriptor) {
1938
+ runtimeDescriptor = {
1939
+ ...runtimeDescriptor,
1940
+ runtimeDescriptor: buildClaudeRuntimeDescriptor({
1941
+ models: runtimeModels,
1942
+ workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1943
+ workspaceRoots: workspaceRootMetadata,
1944
+ executionModes: hostAvailableExecutionModes,
1945
+ presentation: runtimePresentation,
1946
+ }),
1947
+ };
1948
+ }
1714
1949
  await Promise.all(Array.from(knownConversationIds).map(async (conversationId) => {
1715
1950
  const session = sessions.get(conversationId);
1716
1951
  const workspaceId = session
@@ -1793,9 +2028,39 @@ export async function main() {
1793
2028
  const sessions = new Map();
1794
2029
  const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
1795
2030
  const pendingSessionCreations = new Map();
1796
- let controlStopped = false;
1797
- const lastSeenControl = new Map();
1798
- const lastSeenSignal = new Map();
2031
+ // Shared /control channel poller (claude host profile): session + signal
2032
+ // keys, sequential conversations, active/idle cadence + jitter sampled at
2033
+ // cycle start, eager baselines at session creation, and nodes left in place
2034
+ // when a handler throws (consumeOnError defaults to false for both keys).
2035
+ const controlPoller = new ControlChannelPoller({
2036
+ rtdb,
2037
+ agentId,
2038
+ conversationIds: () => sessions.keys(),
2039
+ cadence: {
2040
+ kind: 'adaptive',
2041
+ activeMs: CONTROL_POLL_MS,
2042
+ idleMs: IDLE_CONTROL_POLL_MS,
2043
+ jitterMs: CONTROL_POLL_JITTER_MS,
2044
+ hasActiveWork: () => [...sessions.values()].some((session) => !session.closed
2045
+ && (session.state.state === 'running'
2046
+ || session.state.state === 'requires_action'
2047
+ || session.pendingInputs.length > 0
2048
+ || Boolean(session.pendingFinalDelivery))),
2049
+ activitySample: 'cycle-start',
2050
+ },
2051
+ pollOnStart: true,
2052
+ conversationConcurrency: 'sequential',
2053
+ handlers: {
2054
+ session: { handle: applyRuntimeSessionControl },
2055
+ signal: { handle: handleRuntimeControlSignal },
2056
+ },
2057
+ onError: ({ scope, key, conversationId, error }) => {
2058
+ if (scope === 'read')
2059
+ return; // Transient RTDB errors — retry next cycle
2060
+ const convo = conversationId ? ` [${conversationId.slice(0, 8)}]` : '';
2061
+ console.error(`[canon-host]${convo} Control ${key ?? 'poll'} ${scope} error:`, error);
2062
+ },
2063
+ });
1799
2064
  /** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
1800
2065
  const savedSessionIds = new Map();
1801
2066
  const { getConversationMeta } = createConversationMetadataLoader({
@@ -2072,10 +2337,7 @@ export async function main() {
2072
2337
  ownerId,
2073
2338
  }, resumeId);
2074
2339
  sessions.set(conversationId, session);
2075
- await Promise.all([
2076
- baselineControlSignal(conversationId),
2077
- baselineSessionControl(conversationId),
2078
- ]);
2340
+ await controlPoller.baseline([conversationId]);
2079
2341
  return session;
2080
2342
  }
2081
2343
  catch (error) {
@@ -2091,28 +2353,6 @@ export async function main() {
2091
2353
  pendingSessionCreations.delete(conversationId);
2092
2354
  }
2093
2355
  }
2094
- async function baselineControlSignal(conversationId) {
2095
- if (lastSeenSignal.has(conversationId))
2096
- return;
2097
- const raw = await rtdbRead(`/control/${conversationId}/${agentId}/signal`).catch(() => null);
2098
- if (!raw || typeof raw !== 'object')
2099
- return;
2100
- const timestamp = Number(raw.updatedAt ?? 0);
2101
- if (timestamp > 0) {
2102
- lastSeenSignal.set(conversationId, timestamp);
2103
- }
2104
- }
2105
- async function baselineSessionControl(conversationId) {
2106
- if (lastSeenControl.has(conversationId))
2107
- return;
2108
- const raw = await rtdbRead(`/control/${conversationId}/${agentId}/session`).catch(() => null);
2109
- if (!raw || typeof raw !== 'object')
2110
- return;
2111
- const timestamp = Number(raw.updatedAt ?? 0);
2112
- if (timestamp > 0) {
2113
- lastSeenControl.set(conversationId, timestamp);
2114
- }
2115
- }
2116
2356
  async function enqueueRecoveredInboundMessage(input) {
2117
2357
  const m = input.message;
2118
2358
  if (m.senderId === agentId)
@@ -2217,22 +2457,23 @@ export async function main() {
2217
2457
  conversationId: conversation.id,
2218
2458
  baseCwd: workingDir,
2219
2459
  })?.lastInboundMessageId;
2220
- const page = await client.getMessagesPage(conversation.id, 25);
2221
- const inboundMessages = page.messages
2222
- .filter((message) => message.senderId !== agentId)
2223
- .sort((a, b) => String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')));
2224
- const cursorIndex = cursor
2225
- ? inboundMessages.findIndex((message) => message.id === cursor)
2226
- : -1;
2227
- const messagesToRecover = cursorIndex >= 0
2228
- ? inboundMessages.slice(cursorIndex + 1)
2229
- : inboundMessages.slice(-1);
2230
- for (const message of messagesToRecover) {
2460
+ const recovery = await collectMissedInboundMessages({
2461
+ fetchPage: (before) => client.getMessagesPage(conversation.id, STARTUP_RECOVERY_PAGE_SIZE, before),
2462
+ cursor,
2463
+ agentId,
2464
+ });
2465
+ if (recovery.mode === 'truncated-window') {
2466
+ console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Startup recovery cursor not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; recovering truncated window`);
2467
+ }
2468
+ if (recovery.messages.length > 1) {
2469
+ console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Recovered ${recovery.messages.length} missed messages in ${conversation.id}`);
2470
+ }
2471
+ for (const message of recovery.messages) {
2231
2472
  console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Recovering inbound message on startup`);
2232
2473
  await enqueueRecoveredInboundMessage({
2233
2474
  conversationId: conversation.id,
2234
2475
  message: message,
2235
- hydratedPage: page,
2476
+ hydratedPage: recovery.newestPage,
2236
2477
  });
2237
2478
  }
2238
2479
  }
@@ -2425,139 +2666,107 @@ export async function main() {
2425
2666
  console.error('[canon-host] SSE start error:', err instanceof Error ? err.message : err);
2426
2667
  });
2427
2668
  // ── Control signal watcher ──
2428
- const pollControl = async () => {
2429
- while (!controlStopped) {
2430
- const hadActiveWork = [...sessions.values()].some((session) => !session.closed
2431
- && (session.state.state === 'running'
2432
- || session.state.state === 'requires_action'
2433
- || session.pendingInputs.length > 0
2434
- || Boolean(session.pendingFinalDelivery)));
2435
- for (const convoId of [...sessions.keys()]) {
2436
- try {
2437
- // Session control signals (model, mode)
2438
- const data = await rtdbRead(`/control/${convoId}/${agentId}/session`);
2439
- if (data && typeof data === 'object') {
2440
- const ctrl = data;
2441
- const ts = ctrl.updatedAt ?? 0;
2442
- if (ts > (lastSeenControl.get(convoId) ?? 0)) {
2443
- lastSeenControl.set(convoId, ts);
2444
- const session = sessions.get(convoId);
2445
- if (session && !session.closed) {
2446
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
2447
- if (ctrl.model && ctrl.model !== session.state.model) {
2448
- try {
2449
- await session.query.setModel(ctrl.model);
2450
- session.state.model = ctrl.model;
2451
- session.clearRuntimeControlError('model');
2452
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Model → ${ctrl.model}`);
2453
- }
2454
- catch (err) {
2455
- session.setRuntimeControlError('model', ctrl.model, err);
2456
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
2457
- }
2458
- }
2459
- const nextPermissionMode = parseClaudePermissionMode(ctrl.permissionMode);
2460
- if (ctrl.permissionMode && !nextPermissionMode) {
2461
- session.setRuntimeControlError('permissionMode', ctrl.permissionMode, new Error(`Unsupported Claude permission mode: ${ctrl.permissionMode}`));
2462
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Unsupported mode: ${ctrl.permissionMode}`);
2463
- }
2464
- else if (nextPermissionMode && nextPermissionMode !== session.state.permissionMode) {
2465
- try {
2466
- await session.query.setPermissionMode(nextPermissionMode);
2467
- session.state.permissionMode = nextPermissionMode;
2468
- session.clearRuntimeControlError('permissionMode');
2469
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${nextPermissionMode}`);
2470
- }
2471
- catch (err) {
2472
- session.setRuntimeControlError('permissionMode', nextPermissionMode, err);
2473
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
2474
- }
2475
- }
2476
- const nextEffort = normalizeClaudeEffortLevel(ctrl.effort);
2477
- if (nextEffort && nextEffort !== session.state.effort) {
2478
- try {
2479
- await applyClaudeEffortLevel(session.query, nextEffort);
2480
- session.state.effort = nextEffort;
2481
- session.clearRuntimeControlError('effort');
2482
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Effort → ${nextEffort}`);
2483
- }
2484
- catch (err) {
2485
- session.setRuntimeControlError('effort', nextEffort, err);
2486
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err);
2487
- }
2488
- }
2489
- else if (ctrl.effort && !nextEffort) {
2490
- session.setRuntimeControlError('effort', String(ctrl.effort), new Error('Unsupported Claude thinking level.'));
2491
- }
2492
- session.writeState();
2493
- await rtdbWrite(`/control/${convoId}/${agentId}/session`, null).catch(() => { });
2494
- }
2495
- }
2496
- }
2497
- // Interrupt/stop-clear signal
2498
- const sig = await rtdbRead(`/control/${convoId}/${agentId}/signal`);
2499
- if (sig && typeof sig === 'object') {
2500
- const s = sig;
2501
- const ts = s.updatedAt ?? 0;
2502
- if ((s.type === 'interrupt' || s.type === 'stop_and_drop' || s.type === 'new_session')
2503
- && ts > (lastSeenSignal.get(convoId) ?? 0)) {
2504
- lastSeenSignal.set(convoId, ts);
2505
- const session = sessions.get(convoId);
2506
- if (session && !session.closed) {
2507
- if (s.type === 'new_session') {
2508
- console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
2509
- await resetRuntimeSession(session);
2510
- await rtdbWrite(`/control/${convoId}/${agentId}/signal`, null).catch(() => { });
2511
- continue;
2512
- }
2513
- const isActive = session.state.state === 'running';
2514
- if (!isActive && (s.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
2515
- await rtdbWrite(`/control/${convoId}/${agentId}/signal`, null).catch(() => { });
2516
- continue;
2517
- }
2518
- console.error(`[canon-host] [${convoId.slice(0, 8)}] ${s.type} signal`);
2519
- try {
2520
- if (s.type === 'stop_and_drop') {
2521
- const droppedInputs = session.pendingInputs.splice(0);
2522
- await markSessionPendingInputsRejected(convoId, droppedInputs);
2523
- }
2524
- if (session.activeInput) {
2525
- session.interruptedTurnKeys.add(session.activeInput.turnKey);
2526
- }
2527
- session.pendingFinalText = null;
2528
- session.turnState = 'interrupted';
2529
- session.clearStreaming().catch(() => { });
2530
- typingSignals.clear(convoId).catch(() => { });
2531
- runtimeState.writeTurnState(convoId, {
2532
- turnId: session.currentTurnId,
2533
- state: 'interrupted',
2534
- queueDepth: session.pendingInputs.length,
2535
- currentSpeakerId: agentId,
2536
- lastAcceptedIntent: session.lastAcceptedIntent,
2537
- capabilities: CLAUDE_RUNTIME_CAPABILITIES,
2538
- ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
2539
- completedAt: { '.sv': 'timestamp' },
2540
- }).catch(() => { });
2541
- if (isActive) {
2542
- await session.query.interrupt();
2543
- }
2544
- await rtdbWrite(`/control/${convoId}/${agentId}/signal`, null).catch(() => { });
2545
- }
2546
- catch (err) {
2547
- console.error(`[canon-host] [${convoId.slice(0, 8)}] ${s.type} failed:`, err);
2548
- }
2549
- }
2550
- }
2551
- }
2552
- }
2553
- catch {
2554
- // Transient RTDB errors — ignore and retry next cycle
2555
- }
2669
+ // Session control signals (model, mode, effort). A missing/closed session
2670
+ // leaves the node in place; lastSeen still advances, so a stale control is
2671
+ // never replayed once a session exists.
2672
+ async function applyRuntimeSessionControl(event) {
2673
+ const convoId = event.conversationId;
2674
+ const session = sessions.get(convoId);
2675
+ if (!session || session.closed)
2676
+ return { consume: false };
2677
+ const ctrl = event.control;
2678
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
2679
+ if (ctrl.model && ctrl.model !== session.state.model) {
2680
+ try {
2681
+ await session.query.setModel(ctrl.model);
2682
+ session.state.model = ctrl.model;
2683
+ session.clearRuntimeControlError('model');
2684
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Model → ${ctrl.model}`);
2685
+ }
2686
+ catch (err) {
2687
+ session.setRuntimeControlError('model', ctrl.model, err);
2688
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
2556
2689
  }
2557
- await new Promise((r) => setTimeout(r, controlPollDelayMs(hadActiveWork)));
2558
2690
  }
2559
- };
2560
- pollControl().catch((err) => console.error('[canon-host] Control poll crashed:', err));
2691
+ const nextPermissionMode = parseClaudePermissionMode(ctrl.permissionMode);
2692
+ if (ctrl.permissionMode && !nextPermissionMode) {
2693
+ session.setRuntimeControlError('permissionMode', ctrl.permissionMode, new Error(`Unsupported Claude permission mode: ${ctrl.permissionMode}`));
2694
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Unsupported mode: ${ctrl.permissionMode}`);
2695
+ }
2696
+ else if (nextPermissionMode && nextPermissionMode !== session.state.permissionMode) {
2697
+ try {
2698
+ await session.query.setPermissionMode(nextPermissionMode);
2699
+ session.state.permissionMode = nextPermissionMode;
2700
+ session.clearRuntimeControlError('permissionMode');
2701
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${nextPermissionMode}`);
2702
+ }
2703
+ catch (err) {
2704
+ session.setRuntimeControlError('permissionMode', nextPermissionMode, err);
2705
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
2706
+ }
2707
+ }
2708
+ const nextEffort = normalizeClaudeEffortLevel(ctrl.effort);
2709
+ if (nextEffort && nextEffort !== session.state.effort) {
2710
+ try {
2711
+ await applyClaudeEffortLevel(session.query, nextEffort);
2712
+ session.state.effort = nextEffort;
2713
+ session.clearRuntimeControlError('effort');
2714
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Effort → ${nextEffort}`);
2715
+ }
2716
+ catch (err) {
2717
+ session.setRuntimeControlError('effort', nextEffort, err);
2718
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err);
2719
+ }
2720
+ }
2721
+ else if (ctrl.effort && !nextEffort) {
2722
+ session.setRuntimeControlError('effort', String(ctrl.effort), new Error('Unsupported Claude thinking level.'));
2723
+ }
2724
+ session.writeState();
2725
+ }
2726
+ // Interrupt/stop-clear signal. Throwing leaves the node in place
2727
+ // (consumeOnError: false); lastSeen has already advanced, so the failed
2728
+ // signal is reported but never retried.
2729
+ async function handleRuntimeControlSignal(event) {
2730
+ const convoId = event.conversationId;
2731
+ const session = sessions.get(convoId);
2732
+ if (!session || session.closed)
2733
+ return { consume: false };
2734
+ if (event.type === 'new_session') {
2735
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
2736
+ await resetRuntimeSession(session);
2737
+ return;
2738
+ }
2739
+ const isActive = session.state.state === 'running';
2740
+ if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
2741
+ return; // Nothing to interrupt — consume the stale signal
2742
+ }
2743
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
2744
+ if (event.type === 'stop_and_drop') {
2745
+ const droppedInputs = session.pendingInputs.splice(0);
2746
+ await markSessionPendingInputsRejected(convoId, droppedInputs);
2747
+ }
2748
+ if (session.activeInput) {
2749
+ session.interruptedTurnKeys.add(session.activeInput.turnKey);
2750
+ }
2751
+ session.pendingFinalText = null;
2752
+ session.turnState = 'interrupted';
2753
+ session.clearStreaming().catch(() => { });
2754
+ typingSignals.clear(convoId).catch(() => { });
2755
+ runtimeState.writeTurnState(convoId, {
2756
+ turnId: session.currentTurnId,
2757
+ state: 'interrupted',
2758
+ queueDepth: session.pendingInputs.length,
2759
+ currentSpeakerId: agentId,
2760
+ lastAcceptedIntent: session.lastAcceptedIntent,
2761
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
2762
+ ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
2763
+ completedAt: { '.sv': 'timestamp' },
2764
+ }).catch(() => { });
2765
+ if (isActive) {
2766
+ await session.query.interrupt();
2767
+ }
2768
+ }
2769
+ controlPoller.start();
2561
2770
  // ── Heartbeat (per-session) ──
2562
2771
  const heartbeat = setInterval(() => {
2563
2772
  for (const session of sessions.values()) {
@@ -2584,7 +2793,7 @@ export async function main() {
2584
2793
  // ── Graceful shutdown ──
2585
2794
  const shutdown = async () => {
2586
2795
  console.error('[canon-host] Shutting down...');
2587
- controlStopped = true;
2796
+ controlPoller.stop();
2588
2797
  clearInterval(heartbeat);
2589
2798
  clearInterval(idleCheck);
2590
2799
  stream.stop();