@canonmsg/claude-code-plugin 0.22.0 → 0.23.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
@@ -26,19 +26,14 @@ import { execFileSync } from 'node:child_process';
26
26
  import { existsSync } from 'node:fs';
27
27
  import { readFile } from 'node:fs/promises';
28
28
  import { query, } from '@anthropic-ai/claude-agent-sdk';
29
- import { inferUploadMimeType, isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
30
- import { resolvePackagedBridgeBin } from './bridge-bin.js';
31
- import { connectBridge } from '@canonmsg/framework';
32
- import { STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, attachHostControlNotifications, captureTurnArtifactSnapshot, collectMissedInboundMessages, collectTurnArtifacts, createBufferedNotificationSource, createConversationDirectory, createFinalDelivery, createHitlWaiters, createPortApprovalClient, createPortTypingSignals, createRuntimeSignalHandler, createRuntimeWriters, createSessionLifecycle, createTurnQueue, decideAutoReply, } from '@canonmsg/agent-host';
33
- import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonTurnContextV2, buildFirstPartyCodingRuntimeDescriptor, buildPlanApprovalRequest, resolveQuestionAllowOther, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, EXECUTION_ENVIRONMENT_MODES, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, normalizeTurnMetadata, renderCanonHostInboundContent, renderCodingHostInboundPrompt, } from '@canonmsg/core/contract';
34
- import { ExecutionEnvironmentError, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildLocalRuntimeId, buildPublicWorkspaceOptions, buildPublicWorkspaceRoots, clearRuntimeSessionState, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, prepareConversationEnvironment, releaseConversationEnvironment, resolveCanonAgent, saveRuntimeSessionState, upsertLocalRuntimeEntry, } from '@canonmsg/core/local';
35
- import { ApprovalManager, readHostSessionConfig, resolveHostWorkspaceCwd, } from '@canonmsg/core/host';
29
+ import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
30
+ import { captureTurnArtifactSnapshot, collectTurnArtifacts, } from '@canonmsg/coding-agent-host';
31
+ import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPlanApprovalRequest, resolveQuestionAllowOther, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, 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, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
32
+ import { decideAutoReply, } from './inbound-policy.js';
36
33
  import { runCli } from './cli-entry.js';
37
34
  import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
38
35
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
39
- import { applyClaudeSessionControl, buildClaudeFinalMessageId, buildClaudeInitialSessionState, claudeModelInfoToOption, createClaudeInputEnvelope, formatClaudeControlError, isClaudeCustomModelOption, normalizeClaudeEffortLevel, parseClaudePermissionMode, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
40
- import { claudeThinkingToConfig, parseClaudeSessionDefaults, resolveEffectiveSessionSettings, } from './session-defaults.js';
41
- import { mapClaudeSubagentActivity } from './subagent-activity.js';
36
+ import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
42
37
  function parseRuntimeVisibilityPreset(value) {
43
38
  return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
44
39
  }
@@ -77,11 +72,6 @@ Keep this terminal open while you want Canon to reach the agent. Closing it,
77
72
  logging out, rebooting, or sleeping long enough to stop the process takes the
78
73
  local agent offline until you revive it. Docs:
79
74
  https://canonmail.com/agents/integrations`;
80
- // This host process resolves and locks exactly one agent profile. The lock
81
- // handle returned by resolveCanonAgent is held here so the top-level runCli
82
- // error handler (outside main's scope) can release it on a failed start —
83
- // there is no core module-global profile-lock to fall back on.
84
- let activeLockHandle = null;
85
75
  let workingDir = process.cwd();
86
76
  let workspaceOptions = [];
87
77
  let workspaceRoots = [];
@@ -89,29 +79,6 @@ let workspaceRootMetadata = [];
89
79
  let runtimeModels = [];
90
80
  const CLAUDE_METADATA_TTL_MS = 5 * 60 * 1000;
91
81
  const allowedNonOwnerClaudeTools = parseAllowedNonOwnerClaudeTools(process.env.CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS);
92
- const CLAUDE_THINKING_CONTROL_OPTIONS = [
93
- {
94
- value: 'adaptive',
95
- label: 'Adaptive',
96
- description: 'Let Claude decide when and how much to think.',
97
- },
98
- {
99
- value: 'off',
100
- label: 'Off',
101
- description: 'Disable extended thinking for this session.',
102
- },
103
- ];
104
- const CLAUDE_ULTRACODE_CONTROL_OPTIONS = [
105
- {
106
- value: 'off',
107
- label: 'Off',
108
- },
109
- {
110
- value: 'on',
111
- label: 'On',
112
- description: 'Enable Claude Code ultracode mode when the selected model supports it.',
113
- },
114
- ];
115
82
  let runtimeMetadata = {
116
83
  account: null,
117
84
  mcpServers: [],
@@ -236,40 +203,15 @@ function buildClaudeRuntimeDescriptor(input) {
236
203
  RUNTIME_STOP_AND_DROP_ACTION,
237
204
  ];
238
205
  commands.push(...buildNativeSlashCommandDescriptors(runtimeMetadata.commands, new Set(commands.flatMap((command) => [command.id, ...(command.aliases ?? [])]))));
239
- const extraRuntimeControls = [
240
- {
241
- id: 'thinking',
242
- label: 'Thinking',
243
- description: 'Claude SDK thinking mode. Mid-session changes toggle thinking on or off; display/budget stay session-start scoped.',
244
- options: CLAUDE_THINKING_CONTROL_OPTIONS,
245
- defaultValue: input.defaults?.thinking ?? 'adaptive',
246
- availability: 'setup_and_live',
247
- liveBehavior: 'immediate',
248
- selectionPolicy: 'inherit',
249
- },
250
- {
251
- id: 'ultracode',
252
- label: 'Ultracode',
253
- description: 'Enable Claude Code ultracode mode when the selected model supports it.',
254
- options: CLAUDE_ULTRACODE_CONTROL_OPTIONS,
255
- defaultValue: input.defaults?.ultracode ?? 'off',
256
- availability: 'setup_and_live',
257
- liveBehavior: 'immediate',
258
- selectionPolicy: 'inherit',
259
- },
260
- ];
261
206
  return buildFirstPartyCodingRuntimeDescriptor({
262
207
  clientType: 'claude-code',
263
208
  models: input.models,
264
- defaultModel: input.defaults?.model ?? input.models[0]?.value ?? null,
265
209
  workspaces: input.workspaces,
266
210
  workspaceRoots: input.workspaceRoots,
267
211
  executionModes: input.executionModes,
268
212
  permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
269
213
  defaultPermissionMode: 'default',
270
214
  effortOptions: [...CLAUDE_EFFORT_OPTIONS],
271
- defaultEffort: input.defaults?.effort,
272
- extraRuntimeControls,
273
215
  streamingTextMode: 'delta',
274
216
  turnModes: [
275
217
  {
@@ -295,6 +237,26 @@ function buildClaudeRuntimeDescriptor(input) {
295
237
  commands,
296
238
  });
297
239
  }
240
+ const CLAUDE_EFFORT_VALUES = new Set(CLAUDE_EFFORT_OPTIONS.map((option) => option.value));
241
+ function parseClaudePermissionMode(value) {
242
+ if (value === 'acceptEdits'
243
+ || value === 'auto'
244
+ || value === 'bypassPermissions'
245
+ || value === 'default'
246
+ || value === 'dontAsk'
247
+ || value === 'plan') {
248
+ return value;
249
+ }
250
+ return null;
251
+ }
252
+ function normalizeClaudeEffortLevel(value) {
253
+ return typeof value === 'string' && CLAUDE_EFFORT_VALUES.has(value)
254
+ ? value
255
+ : undefined;
256
+ }
257
+ async function applyClaudeEffortLevel(q, effort) {
258
+ await q.applyFlagSettings({ effortLevel: effort });
259
+ }
298
260
  let cachedClaudeCliPath;
299
261
  // The SDK bundles a Bun-compiled `claude` Mach-O binary next to
300
262
  // claude-agent-sdk-darwin-arm64. On some macOS setups that exact binary gets
@@ -331,30 +293,21 @@ function resolveClaudeCliPath() {
331
293
  console.error('[canon-host] claude CLI not on PATH; using SDK-bundled binary');
332
294
  return undefined;
333
295
  }
334
- const CLAUDE_SUPPLEMENTAL_MODEL_PROBES = ['opus'];
335
- function mergeModelOptions(...groups) {
336
- const byValue = new Map();
337
- for (const group of groups) {
338
- for (const option of group) {
339
- if (!byValue.has(option.value)) {
340
- byValue.set(option.value, option);
341
- }
342
- }
343
- }
344
- return [...byValue.values()];
345
- }
346
296
  function toModelOptions(models) {
347
- return models.map(claudeModelInfoToOption);
297
+ return models.map((model) => ({
298
+ value: model.value,
299
+ label: model.displayName,
300
+ }));
348
301
  }
349
- async function publishAgentRuntime(port, runtime) {
350
- await port.publishHostAgentRuntime({
351
- clientType: 'claude-code',
352
- runtime: runtime,
353
- });
302
+ async function publishAgentRuntime(agentId, runtime) {
303
+ await publishHostAgentRuntime(agentId, 'claude-code', runtime);
354
304
  }
355
- async function loadSessionConfig(port, conversationId) {
356
- const raw = await port.getSessionConfig({ conversationId });
357
- const config = readHostSessionConfig(raw, ['permissionMode', 'effort']);
305
+ async function loadSessionConfig(conversationId, agentId) {
306
+ const config = await loadHostSessionConfig({
307
+ conversationId,
308
+ agentId,
309
+ extraStringFields: ['permissionMode', 'effort'],
310
+ });
358
311
  return {
359
312
  ...config,
360
313
  permissionMode: config?.permissionMode
@@ -384,21 +337,6 @@ function resolveExecutionFallbackReason(environment) {
384
337
  : environment.reason;
385
338
  }
386
339
  async function detectRuntimeModels(cwd) {
387
- const discovered = await detectRuntimeModelsForSelection(cwd, 'default');
388
- const supplemental = await Promise.all(CLAUDE_SUPPLEMENTAL_MODEL_PROBES.map(async (model) => {
389
- try {
390
- const options = await detectRuntimeModelsForSelection(cwd, model);
391
- const option = options.find((entry) => entry.value === model);
392
- return option && !isClaudeCustomModelOption(option) ? [option] : [];
393
- }
394
- catch (error) {
395
- console.error(`[canon-host] Supplemental Claude model probe failed for ${model}:`, error);
396
- return [];
397
- }
398
- }));
399
- return mergeModelOptions(discovered, ...supplemental);
400
- }
401
- async function detectRuntimeModelsForSelection(cwd, model) {
402
340
  const emptyPrompt = {
403
341
  async *[Symbol.asyncIterator]() { },
404
342
  };
@@ -408,7 +346,6 @@ async function detectRuntimeModelsForSelection(cwd, model) {
408
346
  options: {
409
347
  systemPrompt: { type: 'preset', preset: 'claude_code' },
410
348
  tools: { type: 'preset', preset: 'claude_code' },
411
- model,
412
349
  permissionMode: 'default',
413
350
  cwd,
414
351
  settings: { forceLoginMethod: 'claudeai' },
@@ -436,7 +373,6 @@ async function inspectClaudeRuntimeMetadata(cwd, activeQuery) {
436
373
  options: {
437
374
  systemPrompt: { type: 'preset', preset: 'claude_code' },
438
375
  tools: { type: 'preset', preset: 'claude_code' },
439
- model: 'default',
440
376
  permissionMode: 'default',
441
377
  cwd,
442
378
  settings: { forceLoginMethod: 'claudeai' },
@@ -486,6 +422,9 @@ async function refreshClaudeRuntimeMetadata(input) {
486
422
  });
487
423
  return runtimeMetadataRefresh;
488
424
  }
425
+ function sleep(ms) {
426
+ return new Promise((resolve) => setTimeout(resolve, ms));
427
+ }
489
428
  function mapClaudeQuestions(value) {
490
429
  if (!Array.isArray(value))
491
430
  return undefined;
@@ -617,22 +556,13 @@ function buildClaudeRuntimeInfo(input) {
617
556
  tier: 'diagnostic',
618
557
  }
619
558
  : null,
620
- input.fallbackModel
559
+ runtimeModels[0]
621
560
  ? {
622
561
  id: 'fallbackModel',
623
562
  label: 'Fallback model',
624
- value: input.fallbackModel,
563
+ value: runtimeModels[0].label,
625
564
  tier: 'diagnostic',
626
- source: 'applied',
627
- }
628
- : null,
629
- input.ultracodeEnabled
630
- ? {
631
- id: 'ultracode',
632
- label: 'Ultracode',
633
- value: 'Enabled',
634
- tier: 'detail',
635
- source: 'applied',
565
+ source: 'host-default',
636
566
  }
637
567
  : null,
638
568
  {
@@ -725,6 +655,9 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
725
655
  const STREAMING_THROTTLE_MS = 250;
726
656
  const HEARTBEAT_MS = 30_000;
727
657
  const IDLE_CHECK_MS = 60_000;
658
+ const CONTROL_POLL_MS = 2000;
659
+ const IDLE_CONTROL_POLL_MS = 10_000;
660
+ const CONTROL_POLL_JITTER_MS = 1_000;
728
661
  const FINAL_DELIVERY_RETRY_MS = 30_000;
729
662
  const MAX_FINAL_DELIVERY_RETRIES = 1;
730
663
  const CLAUDE_RUNTIME_CAPABILITIES = {
@@ -793,7 +726,7 @@ async function buildCanonUserContent(input) {
793
726
  ];
794
727
  }
795
728
  // ── Session factory ─────────────────────────────────────────────────
796
- function createSession(conversationId, environment, agentId, port, typingSignals, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
729
+ function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
797
730
  const { cwd } = environment;
798
731
  const cardResponseUserId = config.ownerId ?? undefined;
799
732
  const cardResponseUser = cardResponseUserId ? { responseUserId: cardResponseUserId } : undefined;
@@ -853,15 +786,48 @@ function createSession(conversationId, environment, agentId, port, typingSignals
853
786
  // eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
854
787
  let session;
855
788
  const REPLY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
856
- // ── HITL waiters (generic skeleton from @canonmsg/agent-host) ──
857
- const hitlWaiters = createHitlWaiters({
858
- conversationId,
859
- replyTimeoutMs: REPLY_TIMEOUT_MS,
860
- }, {
861
- session: () => session,
862
- port,
863
- });
864
- const { waitForReply, waitForRuntimeInputResponse } = hitlWaiters;
789
+ function waitForReply(type, requestId) {
790
+ // Keep session alive while waiting for user input
791
+ session.lastActivity = Date.now();
792
+ return new Promise((resolve, reject) => {
793
+ const timer = setTimeout(() => {
794
+ session.pendingReply = null;
795
+ reject(new Error('Timed out waiting for user reply'));
796
+ }, REPLY_TIMEOUT_MS);
797
+ session.pendingReply = {
798
+ type,
799
+ requestId,
800
+ resolve: (metadata) => {
801
+ clearTimeout(timer);
802
+ resolve(metadata);
803
+ },
804
+ };
805
+ });
806
+ }
807
+ async function waitForRuntimeInputResponse(input) {
808
+ session.lastActivity = Date.now();
809
+ while (Date.now() < input.expiresAt) {
810
+ const response = await client.consumeRuntimeInputResponse({
811
+ conversationId,
812
+ inputId: input.inputId,
813
+ }).catch(() => null);
814
+ if (response?.status === 'submitted') {
815
+ return { status: 'submitted', answers: response.answers, value: response.value };
816
+ }
817
+ if (response?.status === 'cancelled' || response?.status === 'timeout') {
818
+ return { status: response.status };
819
+ }
820
+ await sleep(1_000);
821
+ }
822
+ const response = await client.consumeRuntimeInputResponse({
823
+ conversationId,
824
+ inputId: input.inputId,
825
+ }).catch(() => null);
826
+ if (response?.status === 'submitted') {
827
+ return { status: 'submitted', answers: response.answers, value: response.value };
828
+ }
829
+ return { status: 'timeout' };
830
+ }
865
831
  function buildArtifactMediaMetadata() {
866
832
  return {
867
833
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
@@ -873,27 +839,16 @@ function createSession(conversationId, environment, agentId, port, typingSignals
873
839
  };
874
840
  }
875
841
  const claudeCliPath = resolveClaudeCliPath();
876
- const initialThinkingConfig = claudeThinkingToConfig(config.sessionSettings.thinking);
877
842
  const queryOptions = {
878
843
  systemPrompt: { type: 'preset', preset: 'claude_code' },
879
844
  tools: { type: 'preset', preset: 'claude_code' },
880
- model: config.model ?? 'default',
881
845
  permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
882
846
  allowDangerouslySkipPermissions: true,
883
847
  cwd,
884
848
  includePartialMessages: true,
885
849
  maxTurns: 200,
886
850
  ...(config.effort ? { effort: config.effort } : {}),
887
- ...(initialThinkingConfig ? { thinking: initialThinkingConfig } : {}),
888
- ...(config.sessionSettings.fallbackModel ? { fallbackModel: config.sessionSettings.fallbackModel } : {}),
889
- agentProgressSummaries: config.sessionSettings.agentProgressSummaries ?? true,
890
- ...(config.sessionSettings.agents ? { agents: config.sessionSettings.agents } : {}),
891
- settings: {
892
- forceLoginMethod: 'claudeai',
893
- ...(config.sessionSettings.ultracode
894
- ? { ultracode: config.sessionSettings.ultracode === 'on' }
895
- : {}),
896
- },
851
+ settings: { forceLoginMethod: 'claudeai' },
897
852
  settingSources: ['project', 'local'],
898
853
  ...(claudeCliPath ? { pathToClaudeCodeExecutable: claudeCliPath } : {}),
899
854
  stderr: (data) => {
@@ -908,27 +863,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
908
863
  const questionId = randomUUID();
909
864
  const questions = mapClaudeQuestions(input.questions);
910
865
  const expiresAt = Date.now() + REPLY_TIMEOUT_MS;
911
- await port.requestInput({
866
+ await client.createRuntimeInputRequest({
912
867
  conversationId,
913
868
  inputId: questionId,
914
869
  kind: 'clarify',
915
- expiresAt: new Date(expiresAt).toISOString(),
870
+ expiresAt,
916
871
  title: 'Claude needs input',
917
872
  prompt: questions?.length
918
873
  ? 'Claude needs your input to continue.'
919
874
  : 'Claude needs input to continue.',
920
- ...(questions
921
- ? { questions: questions }
922
- : { questions: [] }),
875
+ ...(questions ? { questions } : {}),
923
876
  sensitive: Boolean(questions?.some((question) => question.isSecret)),
924
877
  ...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
925
878
  native: {
926
879
  runtime: 'claude-code',
927
880
  method: 'AskUserQuestion',
928
881
  requestId: questionId,
929
- ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
882
+ turnId: session.currentTurnId ?? undefined,
930
883
  },
931
- ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
884
+ turnId: session.currentTurnId ?? undefined,
932
885
  });
933
886
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
934
887
  const reply = await waitForRuntimeInputResponse({ inputId: questionId, expiresAt });
@@ -983,9 +936,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
983
936
  ...(cardResponseUser ?? {}),
984
937
  ...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
985
938
  });
986
- await port.sendMessage({
987
- conversationId,
988
- text: planApproval.text,
939
+ await sendMessageWithRetry(client, conversationId, planApproval.text, {
989
940
  messageId: `claude-plan-${planId}`,
990
941
  ...(session.activeSelfContextId
991
942
  ? { selfContextId: session.activeSelfContextId }
@@ -1101,13 +1052,12 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1101
1052
  query: q,
1102
1053
  sendInput,
1103
1054
  enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactRoutingMode),
1104
- state: buildClaudeInitialSessionState({
1105
- model: config.model,
1055
+ state: {
1056
+ model: undefined,
1106
1057
  permissionMode: config.permissionMode,
1107
- effort: config.effort,
1108
- fallbackModel: config.sessionSettings.fallbackModel,
1109
- runtimeControlValues: config.sessionSettings.runtimeControlValues,
1110
- }),
1058
+ effort: undefined,
1059
+ state: 'idle',
1060
+ },
1111
1061
  availableModels: config.availableModels,
1112
1062
  streamingText: '',
1113
1063
  streamingTimer: null,
@@ -1139,92 +1089,155 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1139
1089
  setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
1140
1090
  clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
1141
1091
  };
1142
- // ── Per-session RTDB writers (generic skeleton from @canonmsg/agent-host) ──
1143
- const writers = createRuntimeWriters({
1144
- conversationId,
1145
- agentId,
1146
- capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1147
- fallbackTurnId: `claude-${conversationId}`,
1148
- streamingThrottleMs: STREAMING_THROTTLE_MS,
1149
- requestedControls: {},
1150
- }, {
1151
- session: () => session,
1152
- port,
1153
- buildSessionStateExtras: (writerSession) => ({
1154
- cwd,
1155
- executionMode: writerSession.environment.mode,
1156
- ...(writerSession.environment.branch ? { executionBranch: writerSession.environment.branch } : {}),
1157
- ...(writerSession.environment.worktreePath ? { worktreePath: writerSession.environment.worktreePath } : {}),
1158
- ...(resolveExecutionFallbackReason(writerSession.environment)
1159
- ? { executionFallbackReason: resolveExecutionFallbackReason(writerSession.environment) ?? undefined }
1160
- : {}),
1161
- }),
1162
- formatControlError: formatClaudeControlError,
1092
+ const streamingOutput = createTurnOutputController({
1093
+ turnId: `claude-${conversationId}`,
1094
+ mode: 'delta',
1095
+ throttleMs: STREAMING_THROTTLE_MS,
1096
+ writeSnapshot: (snapshot) => {
1097
+ if (session.closed)
1098
+ return Promise.resolve();
1099
+ const turnId = session.currentTurnId ?? snapshot.turnId;
1100
+ session.streamingText = snapshot.text;
1101
+ const blocks = snapshot.blocks?.map((block) => ({ ...block, turnId }));
1102
+ return runtimeState.writeStreaming(conversationId, {
1103
+ text: snapshot.text,
1104
+ status: snapshot.status,
1105
+ messageId: turnId,
1106
+ turnId,
1107
+ blocks,
1108
+ });
1109
+ },
1110
+ clearSnapshot: () => {
1111
+ session.streamingText = '';
1112
+ return runtimeState.clearStreaming(conversationId);
1113
+ },
1163
1114
  });
1164
- const { streamingOutput, flushStreaming, onStreamDelta, clearStreaming, getFinalTurnTrail, writeState, writeTurn, setRuntimeControlError, clearRuntimeControlError, refreshVisibleWorkSignal, startVisibleWorkSignal, stopVisibleWorkSignal, } = writers;
1165
- // Initial values come from persisted session config and are effective for
1166
- // this query. Publish immediately so controls do not linger as requested.
1167
- writeState();
1115
+ // ── Per-session streaming helpers ──
1116
+ function flushStreaming() {
1117
+ streamingOutput.flush().catch(() => { });
1118
+ }
1119
+ function onStreamDelta(text, segmentId) {
1120
+ if (segmentId) {
1121
+ streamingOutput.appendTextSegmentDelta(segmentId, text);
1122
+ return;
1123
+ }
1124
+ streamingOutput.appendDelta(text);
1125
+ }
1168
1126
  function isActiveTurnInterrupted() {
1169
1127
  const turnKey = session.activeInput?.turnKey;
1170
1128
  return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
1171
1129
  }
1172
- // ── Turn queue + final delivery (generic skeleton from @canonmsg/agent-host) ──
1173
- const turnQueue = createTurnQueue({ conversationId }, {
1174
- session: () => session,
1175
- isSessionRunning: (queueSession) => queueSession.state.state === 'running',
1176
- markSessionRunning: (queueSession) => { queueSession.state.state = 'running'; },
1177
- prepareInput: (input) => prepareArtifactsForInput(input),
1178
- sendInput: (input) => sendInput(input),
1179
- interruptRuntime: (queueSession) => queueSession.query.interrupt(),
1180
- port,
1181
- writers: {
1182
- writeTurn,
1183
- stopVisibleWorkSignal,
1184
- clearStreaming,
1185
- },
1186
- });
1187
- const { startCanonInput, drainPendingInput, markQueuedMessageAccepted } = turnQueue;
1188
- const finalDelivery = createFinalDelivery({
1189
- conversationId,
1190
- logPrefix: '[canon-host]',
1191
- delivery: { mode: 'retry', retryMs: FINAL_DELIVERY_RETRY_MS, maxRetries: MAX_FINAL_DELIVERY_RETRIES },
1192
- }, {
1193
- session: () => session,
1194
- port,
1195
- typingSignals,
1196
- writers: {
1197
- writeState,
1198
- writeTurn,
1199
- stopVisibleWorkSignal,
1200
- clearStreaming,
1201
- getFinalTurnTrail,
1202
- replaceStreamingSnapshot: (text, status) => streamingOutput.replaceSnapshot(text, status),
1203
- },
1204
- resetCompletedTurnState: (deliverySession) => resetClaudeCompletedTurnState(deliverySession),
1205
- shouldDeliverFinal: (turn, deliverySession) => shouldDeliverClaudeFinal({
1130
+ function clearStreaming() {
1131
+ return streamingOutput.clear().catch(() => { });
1132
+ }
1133
+ function getFinalTurnTrail() {
1134
+ const turnId = session.currentTurnId ?? `claude-${conversationId}`;
1135
+ return streamingOutput.getFinalTrail().map((block) => ({ ...block, turnId }));
1136
+ }
1137
+ function clearIdleResetTimer() {
1138
+ if (!session.idleResetTimer)
1139
+ return;
1140
+ clearTimeout(session.idleResetTimer);
1141
+ session.idleResetTimer = null;
1142
+ }
1143
+ function clearFinalDeliveryTimer() {
1144
+ if (!session.finalDeliveryTimer)
1145
+ return;
1146
+ clearTimeout(session.finalDeliveryTimer);
1147
+ session.finalDeliveryTimer = null;
1148
+ }
1149
+ function resetTurnToIdle() {
1150
+ if (session.closed)
1151
+ return;
1152
+ clearFinalDeliveryTimer();
1153
+ resetClaudeCompletedTurnState(session);
1154
+ stopVisibleWorkSignal();
1155
+ writeState();
1156
+ writeTurn();
1157
+ drainPendingInput();
1158
+ }
1159
+ function scheduleFinalHandoffReset() {
1160
+ clearIdleResetTimer();
1161
+ session.idleResetTimer = setTimeout(() => {
1162
+ session.idleResetTimer = null;
1163
+ typingSignals.clear(conversationId).catch(() => { });
1164
+ clearStreaming();
1165
+ resetTurnToIdle();
1166
+ }, FINAL_MESSAGE_HANDOFF_MS);
1167
+ }
1168
+ function markPendingFinalDelivery(finalText, turn) {
1169
+ clearIdleResetTimer();
1170
+ const retryCount = session.pendingFinalDelivery?.turnKey === turn.turnKey
1171
+ ? session.pendingFinalDelivery.retryCount
1172
+ : 0;
1173
+ session.pendingFinalText = finalText;
1174
+ session.pendingFinalDelivery = {
1175
+ turnKey: turn.turnKey,
1176
+ text: finalText,
1177
+ retryCount,
1178
+ messageId: buildClaudeFinalMessageId({
1179
+ agentId,
1180
+ conversationId,
1181
+ turnKey: turn.turnKey,
1182
+ }),
1183
+ };
1184
+ session.state.state = 'running';
1185
+ session.turnState = 'streaming';
1186
+ stopVisibleWorkSignal();
1187
+ streamingOutput.replaceSnapshot(finalText, 'streaming').catch(() => { });
1188
+ writeState();
1189
+ writeTurn();
1190
+ typingSignals.start(conversationId, 'typing').catch(() => { });
1191
+ }
1192
+ async function deliverFinalReply(finalText, turn = session.activeInput) {
1193
+ if (!shouldDeliverClaudeFinal({
1206
1194
  turn,
1207
- finalizedTurnKeys: deliverySession.finalizedTurnKeys,
1208
- interruptedTurnKeys: deliverySession.interruptedTurnKeys,
1209
- }),
1210
- buildFinalMessageId: (turnKey) => buildClaudeFinalMessageId({ agentId, conversationId, turnKey }),
1211
- drainPendingInput: () => drainPendingInput(),
1212
- });
1213
- const { clearIdleResetTimer, clearFinalDeliveryTimer, resetTurnToIdle, scheduleFinalHandoffReset, markPendingFinalDelivery, deliverFinalReply, scheduleFinalDeliveryRetry, } = finalDelivery;
1214
- async function sendTurnArtifactFile(file) {
1215
- // Byte transfer is bridge-owned (§B8): hand the bridge a path, it streams
1216
- // the upload; then send the permanent message with the attachment ref.
1217
- const uploaded = await port.uploadMedia({
1218
- path: file.path,
1219
- mime: inferUploadMimeType(file.path),
1220
- fileName: file.fileName,
1195
+ finalizedTurnKeys: session.finalizedTurnKeys,
1196
+ interruptedTurnKeys: session.interruptedTurnKeys,
1197
+ })) {
1198
+ return true;
1199
+ }
1200
+ const deliverTurn = turn;
1201
+ if (!deliverTurn)
1202
+ return true;
1203
+ const finalMessageId = buildClaudeFinalMessageId({
1204
+ agentId,
1221
1205
  conversationId,
1206
+ turnKey: deliverTurn.turnKey,
1222
1207
  });
1223
- return port.sendMessage({
1224
- conversationId,
1225
- text: '',
1226
- contentType: uploaded.attachment.kind,
1227
- attachments: [uploaded.attachment],
1208
+ try {
1209
+ await sendMessageWithRetry(client, conversationId, finalText, {
1210
+ messageId: finalMessageId,
1211
+ ...(session.activeSelfContextId
1212
+ ? { selfContextId: session.activeSelfContextId }
1213
+ : {}),
1214
+ metadata: {
1215
+ turnId: session.currentTurnId,
1216
+ turnKey: deliverTurn.turnKey,
1217
+ sourceMessageId: deliverTurn.sourceMessageId ?? undefined,
1218
+ turnSemantics: 'turn_complete',
1219
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
1220
+ ...(getFinalTurnTrail().length > 0
1221
+ ? { turnTrail: getFinalTurnTrail() }
1222
+ : {}),
1223
+ },
1224
+ });
1225
+ session.finalizedTurnKeys.add(deliverTurn.turnKey);
1226
+ session.state.state = 'idle';
1227
+ session.pendingFinalDelivery = null;
1228
+ clearFinalDeliveryTimer();
1229
+ writeState();
1230
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars)`);
1231
+ return true;
1232
+ }
1233
+ catch (err) {
1234
+ markPendingFinalDelivery(finalText, deliverTurn);
1235
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
1236
+ return false;
1237
+ }
1238
+ }
1239
+ function sendTurnArtifactFile(file) {
1240
+ return sendMediaFileMessage(client, conversationId, file.path, '', {
1228
1241
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1229
1242
  metadata: buildArtifactMediaMetadata(),
1230
1243
  });
@@ -1261,6 +1274,138 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1261
1274
  await routeWorkspaceGeneratedArtifacts(input);
1262
1275
  }
1263
1276
  }
1277
+ function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
1278
+ if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
1279
+ return;
1280
+ if (pending.retryCount >= MAX_FINAL_DELIVERY_RETRIES) {
1281
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply delivery exhausted after bounded retries`);
1282
+ session.pendingFinalDelivery = null;
1283
+ resetTurnToIdle();
1284
+ return;
1285
+ }
1286
+ pending.retryCount += 1;
1287
+ session.pendingFinalDelivery = pending;
1288
+ clearFinalDeliveryTimer();
1289
+ session.finalDeliveryTimer = setTimeout(() => {
1290
+ session.finalDeliveryTimer = null;
1291
+ const retryTurn = session.activeInput?.turnKey === pending.turnKey
1292
+ ? session.activeInput
1293
+ : null;
1294
+ void deliverFinalReply(pending.text, retryTurn).then((sent) => {
1295
+ if (sent) {
1296
+ scheduleFinalHandoffReset();
1297
+ return;
1298
+ }
1299
+ scheduleFinalDeliveryRetry();
1300
+ });
1301
+ }, FINAL_DELIVERY_RETRY_MS);
1302
+ }
1303
+ function writeState() {
1304
+ const runtimeControlErrors = Object.keys(session.runtimeControlErrors).length > 0
1305
+ ? session.runtimeControlErrors
1306
+ : null;
1307
+ const controlState = {};
1308
+ const appliedAt = Date.now();
1309
+ if (session.state.model !== undefined) {
1310
+ controlState.model = { value: session.state.model, source: 'applied', appliedAt };
1311
+ }
1312
+ else if (config.model !== undefined) {
1313
+ controlState.model = { value: config.model, source: 'requested' };
1314
+ }
1315
+ if (session.state.permissionMode !== undefined) {
1316
+ controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
1317
+ }
1318
+ else if (config.permissionMode !== undefined) {
1319
+ controlState.permissionMode = { value: config.permissionMode, source: 'requested' };
1320
+ }
1321
+ if (session.state.effort !== undefined) {
1322
+ controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
1323
+ }
1324
+ else if (config.effort !== undefined) {
1325
+ controlState.effort = { value: config.effort, source: 'requested' };
1326
+ }
1327
+ runtimeState.writeSessionState(conversationId, {
1328
+ model: session.state.model,
1329
+ permissionMode: session.state.permissionMode,
1330
+ effort: session.state.effort,
1331
+ controlState,
1332
+ runtimeControlErrors,
1333
+ contextUsage: session.state.contextUsage,
1334
+ ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
1335
+ cwd,
1336
+ executionMode: session.environment.mode,
1337
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
1338
+ ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
1339
+ ...(resolveExecutionFallbackReason(session.environment)
1340
+ ? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
1341
+ : {}),
1342
+ hostMode: true,
1343
+ isActive: true,
1344
+ }).catch(() => { });
1345
+ }
1346
+ function setRuntimeControlError(controlId, value, error) {
1347
+ session.runtimeControlErrors = {
1348
+ ...session.runtimeControlErrors,
1349
+ [controlId]: {
1350
+ value,
1351
+ message: formatClaudeControlError(error),
1352
+ updatedAt: Date.now(),
1353
+ },
1354
+ };
1355
+ writeState();
1356
+ }
1357
+ function clearRuntimeControlError(controlId) {
1358
+ if (!(controlId in session.runtimeControlErrors))
1359
+ return;
1360
+ const next = { ...session.runtimeControlErrors };
1361
+ delete next[controlId];
1362
+ session.runtimeControlErrors = next;
1363
+ writeState();
1364
+ }
1365
+ function writeTurn() {
1366
+ const isOpenTurn = session.turnState === 'thinking'
1367
+ || session.turnState === 'streaming'
1368
+ || session.turnState === 'tool'
1369
+ || session.turnState === 'waiting_input';
1370
+ if (isOpenTurn) {
1371
+ session.currentTurnUpdatedAt = Date.now();
1372
+ }
1373
+ runtimeState.writeTurnState(conversationId, {
1374
+ turnId: session.currentTurnId,
1375
+ state: session.turnState,
1376
+ queueDepth: session.pendingInputs.length,
1377
+ currentSpeakerId: agentId,
1378
+ lastAcceptedIntent: session.lastAcceptedIntent,
1379
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1380
+ ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
1381
+ ...(isOpenTurn && session.currentTurnUpdatedAt ? { turnUpdatedAt: session.currentTurnUpdatedAt } : {}),
1382
+ ...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
1383
+ ? { completedAt: { '.sv': 'timestamp' } }
1384
+ : {}),
1385
+ }).catch(() => { });
1386
+ }
1387
+ function refreshVisibleWorkSignal() {
1388
+ if (session.closed)
1389
+ return;
1390
+ if (session.turnState !== 'thinking' && session.turnState !== 'tool')
1391
+ return;
1392
+ typingSignals.start(conversationId, 'thinking').catch(() => { });
1393
+ }
1394
+ function startVisibleWorkSignal() {
1395
+ refreshVisibleWorkSignal();
1396
+ }
1397
+ function stopVisibleWorkSignal() {
1398
+ if (session.typingKeepaliveTimer) {
1399
+ clearInterval(session.typingKeepaliveTimer);
1400
+ session.typingKeepaliveTimer = null;
1401
+ }
1402
+ typingSignals.clear(conversationId).catch(() => { });
1403
+ }
1404
+ async function markQueuedMessageAccepted(sourceMessageId, markAccepted) {
1405
+ if (!markAccepted || !sourceMessageId)
1406
+ return;
1407
+ await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
1408
+ }
1264
1409
  async function prepareArtifactsForInput(input) {
1265
1410
  if (input.kind !== 'canon')
1266
1411
  return;
@@ -1276,8 +1421,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1276
1421
  return;
1277
1422
  }
1278
1423
  }
1424
+ async function startCanonInput(input) {
1425
+ await markQueuedMessageAccepted(input.sourceMessageId, input.markAccepted);
1426
+ await prepareArtifactsForInput(input);
1427
+ session.state.state = 'running';
1428
+ sendInput(input);
1429
+ }
1430
+ function drainPendingInput() {
1431
+ if (session.closed || session.state.state === 'running' || session.pendingInputs.length === 0)
1432
+ return;
1433
+ const next = session.pendingInputs.shift();
1434
+ if (!next)
1435
+ return;
1436
+ session.lastAcceptedIntent = next.intent;
1437
+ writeTurn();
1438
+ void startCanonInput(next);
1439
+ }
1279
1440
  enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
1280
- turnQueue.enqueueCanonInput(createClaudeInputEnvelope({
1441
+ session.lastActivity = Date.now();
1442
+ const input = createClaudeInputEnvelope({
1281
1443
  kind: 'canon',
1282
1444
  msg,
1283
1445
  intent,
@@ -1285,11 +1447,35 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1285
1447
  markAccepted,
1286
1448
  isOwnerTurn,
1287
1449
  artifactRoutingMode,
1288
- }));
1450
+ });
1451
+ if (session.pendingFinalDelivery) {
1452
+ session.pendingInputs.push(input);
1453
+ writeTurn();
1454
+ return;
1455
+ }
1456
+ if (session.state.state === 'running' && intent !== 'interrupt') {
1457
+ session.pendingInputs.push(input);
1458
+ writeTurn();
1459
+ return;
1460
+ }
1461
+ if (session.state.state === 'running' && intent === 'interrupt') {
1462
+ if (session.activeInput) {
1463
+ session.interruptedTurnKeys.add(session.activeInput.turnKey);
1464
+ }
1465
+ session.pendingInputs.unshift(input);
1466
+ session.turnState = 'interrupted';
1467
+ stopVisibleWorkSignal();
1468
+ clearStreaming().catch(() => { });
1469
+ writeTurn();
1470
+ void session.query.interrupt().catch(() => { });
1471
+ return;
1472
+ }
1473
+ session.lastAcceptedIntent = intent;
1474
+ writeTurn();
1475
+ void startCanonInput(input);
1289
1476
  };
1290
1477
  writeTurn();
1291
1478
  // ── Process SDK output messages ──
1292
- let assistantStreamOrdinal = 0;
1293
1479
  const processMessages = async () => {
1294
1480
  try {
1295
1481
  for await (const msg of q) {
@@ -1320,10 +1506,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1320
1506
  stopVisibleWorkSignal();
1321
1507
  break;
1322
1508
  }
1323
- if (event?.type === 'message_start') {
1324
- assistantStreamOrdinal += 1;
1325
- }
1326
- else if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
1509
+ if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
1327
1510
  session.toolInProgress = true;
1328
1511
  session.turnState = 'tool';
1329
1512
  streamingOutput.addBlock({
@@ -1352,35 +1535,12 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1352
1535
  stopVisibleWorkSignal();
1353
1536
  writeTurn();
1354
1537
  const blockIndex = typeof event.index === 'number' ? event.index : 'latest';
1355
- const messageOrdinal = assistantStreamOrdinal || 1;
1356
- onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${messageOrdinal}:${blockIndex}`);
1538
+ onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${blockIndex}`);
1357
1539
  }
1358
1540
  break;
1359
1541
  }
1360
1542
  case 'system': {
1361
1543
  const subtype = msg.subtype;
1362
- if (subtype === 'task_started'
1363
- || subtype === 'task_progress'
1364
- || subtype === 'task_updated'
1365
- || subtype === 'task_notification') {
1366
- const activity = mapClaudeSubagentActivity(msg);
1367
- if (activity && !isActiveTurnInterrupted()) {
1368
- if (!session.currentTurnId) {
1369
- session.currentTurnId = randomUUID();
1370
- session.currentTurnOpenedAt = Date.now();
1371
- }
1372
- if (session.state.state === 'running' && session.turnState !== 'streaming') {
1373
- session.turnState = 'thinking';
1374
- startVisibleWorkSignal();
1375
- writeTurn();
1376
- }
1377
- await streamingOutput.addBlock(activity).catch(() => { });
1378
- }
1379
- }
1380
- if (subtype === 'model_refusal_fallback') {
1381
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Model refusal fallback: `
1382
- + `${msg.original_model} → ${msg.fallback_model}`);
1383
- }
1384
1544
  if (subtype === 'session_state_changed') {
1385
1545
  const state = msg.state;
1386
1546
  session.state.state = state;
@@ -1392,7 +1552,6 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1392
1552
  }
1393
1553
  session.state.contextUsage = undefined;
1394
1554
  session.pendingFinalText = null;
1395
- assistantStreamOrdinal = 0;
1396
1555
  session.toolInProgress = false;
1397
1556
  session.turnState = 'thinking';
1398
1557
  startVisibleWorkSignal();
@@ -1489,6 +1648,24 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1489
1648
  const init = async () => {
1490
1649
  processMessages().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] processMessages crashed:`, err));
1491
1650
  await q.initializationResult();
1651
+ if (config.model) {
1652
+ try {
1653
+ await q.setModel(config.model);
1654
+ session.state.model = config.model;
1655
+ }
1656
+ catch (err) {
1657
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial model:`, err);
1658
+ }
1659
+ }
1660
+ if (config.effort) {
1661
+ try {
1662
+ await applyClaudeEffortLevel(q, config.effort);
1663
+ session.state.effort = config.effort;
1664
+ }
1665
+ catch (err) {
1666
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial effort:`, err);
1667
+ }
1668
+ }
1492
1669
  if (!resumeSessionId) {
1493
1670
  // New session — send seed message to activate streaming input mode
1494
1671
  sendInput(createClaudeInputEnvelope({
@@ -1503,32 +1680,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1503
1680
  },
1504
1681
  }));
1505
1682
  }
1506
- // Refresh the canonical runtime descriptor from a neutral probe. The live
1507
- // session may be initialized with a selected model, which can narrow the
1508
- // SDK's cached model list.
1683
+ // Refresh the canonical runtime descriptor after Claude reports supported models.
1509
1684
  try {
1510
- const discoveredModels = await detectRuntimeModels(cwd).catch((error) => {
1511
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Model discovery failed:`, error);
1512
- return runtimeModels;
1513
- });
1514
- if (discoveredModels.length > 0) {
1515
- runtimeModels = discoveredModels;
1516
- }
1517
- const modelList = resolveClaudeModelOptions({
1518
- discovered: runtimeModels,
1685
+ const models = await q.supportedModels();
1686
+ const modelList = mergeClaudeModelOptions({
1687
+ previous: runtimeModels,
1688
+ next: toModelOptions(models),
1519
1689
  selectedModel: session.state.model,
1520
1690
  });
1521
1691
  session.availableModels = modelList;
1692
+ runtimeModels = modelList;
1522
1693
  onRuntimeDescriptorUpdate({
1523
1694
  defaultWorkspaceId: workspaceOptions[0]?.id,
1524
- defaultModel: config.sessionSettings.model ?? modelList[0]?.value,
1695
+ defaultModel: modelList[0]?.value,
1525
1696
  defaultPermissionMode: 'default',
1526
1697
  availableModels: modelList,
1527
1698
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1528
1699
  availableExecutionModes: config.availableExecutionModes,
1529
1700
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1530
1701
  models: modelList,
1531
- defaults: config.sessionSettings,
1532
1702
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1533
1703
  workspaceRoots: workspaceRootMetadata,
1534
1704
  executionModes: config.availableExecutionModes,
@@ -1536,7 +1706,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1536
1706
  }),
1537
1707
  });
1538
1708
  writeState();
1539
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${modelList.map((m) => m.label).join(', ')}`);
1709
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${models.map((m) => m.displayName).join(', ')}`);
1540
1710
  }
1541
1711
  catch {
1542
1712
  writeState();
@@ -1581,57 +1751,41 @@ export async function main() {
1581
1751
  for (const warning of workspaceDiscovery.warnings) {
1582
1752
  console.error(`[canon-host] ${warning}`);
1583
1753
  }
1584
- const { agentName: profileAgentName, profile, sessionDefaults: rawSessionDefaults, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
1585
- const profileSessionDefaults = parseClaudeSessionDefaults(rawSessionDefaults);
1586
- activeLockHandle = lockHandle ?? null;
1754
+ const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
1587
1755
  console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
1588
- // ── Canon via the Bridge (bridge-execution-plan Phase 6) ──
1589
- // One connectBridge per host process: the daemon owns the SSE stream,
1590
- // RTDB auth, control polling, dedup, and reconnect. Notification handlers
1591
- // must exist BEFORE the hello response lands (replayed frames dispatch
1592
- // synchronously), so a buffered source subscribes inside `configure` and
1593
- // the real handlers attach after the session machinery is built.
1594
- const notifications = createBufferedNotificationSource([
1595
- 'ready',
1596
- 'connectionState',
1597
- 'onMessage',
1598
- 'onMessageDeleted',
1599
- 'onConversationUpdated',
1600
- 'onSessionControl',
1601
- 'onControlSignal',
1602
- 'onControlReply',
1603
- ]);
1604
- const bridge = await connectBridge({
1605
- ...(profile ? { profile } : {}),
1606
- mode: 'auto',
1607
- ...(resolvePackagedBridgeBin() ? { binPath: resolvePackagedBridgeBin() } : {}),
1608
- hello: {
1609
- clientType: 'claude-code',
1610
- wantFamilies: ['messages', 'runtime_turn'],
1611
- },
1612
- configure: (bridgeClient) => notifications.connect(bridgeClient),
1756
+ // ── Canon API client + RTDB auth ──
1757
+ const client = new CanonClient(apiKey, baseUrl);
1758
+ const rtdb = initRTDBAuth(client);
1759
+ const typingSignals = createTypingStatusPublisher({
1760
+ setTyping: (conversationId, typing, status) => status
1761
+ ? client.setTyping(conversationId, typing, status)
1762
+ : client.setTyping(conversationId, typing),
1613
1763
  });
1614
- // The BridgeClient's zod-derived result shapes mirror the core wire types
1615
- // structurally; the port pins the strong types the host machinery uses.
1616
- const port = bridge.client;
1617
- bridge.client.onProtocolError((error) => {
1618
- console.error(`[canon-host] Bridge protocol error: ${error.message}`);
1619
- });
1620
- const typingSignals = createPortTypingSignals(port);
1621
1764
  async function markQueuedMessageAcceptedForConversation(conversationId, sourceMessageId, markAccepted) {
1622
1765
  if (!markAccepted || !sourceMessageId)
1623
1766
  return;
1624
- await port.setDisposition({
1625
- conversationId,
1626
- messageId: sourceMessageId,
1627
- inboundDisposition: 'accepted_now',
1628
- }).catch(() => { });
1767
+ await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
1768
+ }
1769
+ let agentId;
1770
+ let ownerId = null;
1771
+ let ownerName = null;
1772
+ try {
1773
+ const ctx = await client.getAgentMe();
1774
+ agentId = ctx.agentId;
1775
+ ownerId = ctx.ownerId;
1776
+ ownerName = ctx.ownerName;
1777
+ console.error(`[canon-host] Connected as ${ctx.displayName || agentId}`);
1778
+ }
1779
+ catch {
1780
+ if (profileAgentId) {
1781
+ agentId = profileAgentId;
1782
+ }
1783
+ else {
1784
+ const auth = await client.getAuthToken();
1785
+ agentId = auth.agentId;
1786
+ }
1787
+ console.error(`[canon-host] Authenticated as ${agentId}`);
1629
1788
  }
1630
- const agentId = bridge.hello.agentId;
1631
- const ownerId = bridge.hello.agentContext.ownerId ?? null;
1632
- const ownerName = bridge.hello.agentContext.ownerName ?? null;
1633
- console.error(`[canon-host] Connected as ${bridge.hello.agentName || agentId} `
1634
- + `(canon-bridge${bridge.spawnedDaemon ? ' spawned' : ''} at ${bridge.socketPath})`);
1635
1789
  const launchArgs = [...process.argv.slice(2)];
1636
1790
  if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
1637
1791
  launchArgs.push('--cwd', workingDir);
@@ -1660,6 +1814,11 @@ export async function main() {
1660
1814
  lastStartedAt: new Date().toISOString(),
1661
1815
  lastHeartbeatAt: new Date().toISOString(),
1662
1816
  });
1817
+ const runtimeState = createRuntimeStatePublisher({
1818
+ agentId,
1819
+ clientType: 'claude-code',
1820
+ hostMode: true,
1821
+ });
1663
1822
  let streamConnected = false;
1664
1823
  const hostAvailableExecutionModes = [
1665
1824
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1672,60 +1831,23 @@ export async function main() {
1672
1831
  });
1673
1832
  let runtimeDescriptor = {
1674
1833
  defaultWorkspaceId: workspaceOptions[0]?.id,
1675
- defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1676
1834
  defaultPermissionMode: 'default',
1677
1835
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1678
1836
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1679
1837
  availableExecutionModes: hostAvailableExecutionModes,
1680
1838
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1681
1839
  models: runtimeModels,
1682
- defaults: profileSessionDefaults,
1683
1840
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1684
1841
  workspaceRoots: workspaceRootMetadata,
1685
1842
  executionModes: hostAvailableExecutionModes,
1686
1843
  presentation: runtimePresentation,
1687
1844
  }),
1688
1845
  };
1689
- // ── Conversation directory (shared inbound-ingestion bookkeeping from @canonmsg/agent-host) ──
1690
- const directory = createConversationDirectory({
1691
- agentId,
1692
- ownerId,
1693
- ownerName,
1694
- refreshFloorMs: HEARTBEAT_MS,
1695
- }, { port });
1696
- const { conversationCache, knownConversationIds, getConversationMeta, refreshKnownConversationIds, handleConversationUpdated, markGroupContextModeUsed, loadHydratedInboundContext, } = directory;
1697
- const publishSessionSnapshotsForConversations = async (conversationIds) => {
1698
- const ids = Array.from(new Set(conversationIds));
1699
- if (!streamConnected || ids.length === 0)
1700
- return;
1701
- await port.publishHostSessionSnapshots({
1702
- conversationIds: ids,
1703
- clientType: 'claude-code',
1704
- runtime: runtimeDescriptor,
1705
- workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
1706
- defaultCwd: workingDir,
1707
- extraSessionConfigFields: ['permissionMode', 'effort'],
1708
- liveSessionConfigByConversation: Object.fromEntries(Array.from(sessions.values()).map((session) => {
1709
- const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
1710
- return [
1711
- session.conversationId,
1712
- {
1713
- ...(session.state.model ? { model: session.state.model } : {}),
1714
- ...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
1715
- ...(session.state.effort ? { effort: session.state.effort } : {}),
1716
- ...(session.state.runtimeControlValues
1717
- ? { runtimeControlValues: session.state.runtimeControlValues }
1718
- : {}),
1719
- ...(workspaceId ? { workspaceId } : {}),
1720
- executionMode: session.environment.mode,
1721
- executionBranch: session.environment.branch ?? null,
1722
- },
1723
- ];
1724
- })),
1725
- }).catch((error) => {
1726
- console.error('[canon-host] Failed to publish session snapshots:', error);
1727
- });
1728
- };
1846
+ const conversationCache = new Map();
1847
+ const knownConversationIds = new Set();
1848
+ const promptedGroupContextConversationIds = new Set();
1849
+ const pendingMembershipChanges = new Map();
1850
+ let lastKnownConversationRefreshAt = 0;
1729
1851
  const publishRuntimeHeartbeat = async () => {
1730
1852
  heartbeatLocalRuntimeEntry(runtimeId, {
1731
1853
  agentId,
@@ -1746,20 +1868,42 @@ export async function main() {
1746
1868
  console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
1747
1869
  return runtimeMetadata;
1748
1870
  });
1749
- await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
1871
+ await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
1750
1872
  console.error('[canon-host] Failed to publish agent runtime:', error);
1751
1873
  });
1752
- await publishSessionSnapshotsForConversations(knownConversationIds);
1874
+ await publishHostSessionSnapshots({
1875
+ conversationIds: Array.from(knownConversationIds),
1876
+ agentId,
1877
+ clientType: 'claude-code',
1878
+ runtime: runtimeDescriptor,
1879
+ workspaceOptions,
1880
+ defaultCwd: workingDir,
1881
+ extraSessionConfigFields: ['permissionMode', 'effort'],
1882
+ liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
1883
+ const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
1884
+ return [
1885
+ session.conversationId,
1886
+ {
1887
+ ...(session.state.model ? { model: session.state.model } : {}),
1888
+ ...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
1889
+ ...(session.state.effort ? { effort: session.state.effort } : {}),
1890
+ ...(workspaceId ? { workspaceId } : {}),
1891
+ executionMode: session.environment.mode,
1892
+ executionBranch: session.environment.branch ?? null,
1893
+ },
1894
+ ];
1895
+ })),
1896
+ }).catch((error) => {
1897
+ console.error('[canon-host] Failed to publish session snapshots:', error);
1898
+ });
1753
1899
  // Rebuild so the descriptor picks up native slash commands from the
1754
1900
  // metadata refresh above — the initial descriptor is built before the
1755
1901
  // command inventory has loaded.
1756
1902
  if (runtimeDescriptor.runtimeDescriptor) {
1757
1903
  runtimeDescriptor = {
1758
1904
  ...runtimeDescriptor,
1759
- defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1760
1905
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1761
1906
  models: runtimeModels,
1762
- defaults: profileSessionDefaults,
1763
1907
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1764
1908
  workspaceRoots: workspaceRootMetadata,
1765
1909
  executionModes: hostAvailableExecutionModes,
@@ -1787,15 +1931,8 @@ export async function main() {
1787
1931
  executionBranch: session?.environment.branch ?? null,
1788
1932
  worktreePath: session?.environment.worktreePath ?? null,
1789
1933
  fallbackReason: resolveExecutionFallbackReason(session?.environment),
1790
- fallbackModel: session?.state.fallbackModel
1791
- ?? profileSessionDefaults.fallbackModel
1792
- ?? null,
1793
- ultracodeEnabled: (session?.state.runtimeControlValues?.ultracode ?? profileSessionDefaults.ultracode) === 'on',
1794
- });
1795
- await port.publishRuntimeStatus({
1796
- conversationId,
1797
- presentation: payload,
1798
1934
  });
1935
+ await runtimeState.writeRuntimeInfo(conversationId, payload);
1799
1936
  })).catch((error) => {
1800
1937
  console.error('[canon-host] Failed to publish runtime info:', error);
1801
1938
  });
@@ -1805,7 +1942,7 @@ export async function main() {
1805
1942
  runtimeModels = await detectRuntimeModels(workingDir);
1806
1943
  runtimeDescriptor = {
1807
1944
  defaultWorkspaceId: workspaceOptions[0]?.id,
1808
- defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1945
+ defaultModel: runtimeModels[0]?.value,
1809
1946
  defaultPermissionMode: 'default',
1810
1947
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1811
1948
  availableModels: runtimeModels,
@@ -1813,7 +1950,6 @@ export async function main() {
1813
1950
  availableExecutionModes: hostAvailableExecutionModes,
1814
1951
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1815
1952
  models: runtimeModels,
1816
- defaults: profileSessionDefaults,
1817
1953
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1818
1954
  workspaceRoots: workspaceRootMetadata,
1819
1955
  executionModes: hostAvailableExecutionModes,
@@ -1824,14 +1960,12 @@ export async function main() {
1824
1960
  catch {
1825
1961
  runtimeDescriptor = {
1826
1962
  defaultWorkspaceId: workspaceOptions[0]?.id,
1827
- defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1828
1963
  defaultPermissionMode: 'default',
1829
1964
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1830
1965
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1831
1966
  availableExecutionModes: hostAvailableExecutionModes,
1832
1967
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1833
1968
  models: runtimeModels,
1834
- defaults: profileSessionDefaults,
1835
1969
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1836
1970
  workspaceRoots: workspaceRootMetadata,
1837
1971
  executionModes: hostAvailableExecutionModes,
@@ -1840,12 +1974,14 @@ export async function main() {
1840
1974
  };
1841
1975
  }
1842
1976
  try {
1843
- const convos = await directory.listConversations();
1844
- directory.primeFromStartup(convos);
1977
+ const convos = await client.getConversations();
1978
+ lastKnownConversationRefreshAt = Date.now();
1845
1979
  for (const conversation of convos) {
1846
- port.clearStreaming({ conversationId: conversation.id }).catch(() => { });
1847
- port.clearSessionState({ conversationId: conversation.id }).catch(() => { });
1848
- port.clearTurnState({ conversationId: conversation.id }).catch(() => { });
1980
+ knownConversationIds.add(conversation.id);
1981
+ conversationCache.set(conversation.id, conversation);
1982
+ runtimeState.clearStreaming(conversation.id).catch(() => { });
1983
+ runtimeState.clearSessionState(conversation.id).catch(() => { });
1984
+ runtimeState.clearTurnState(conversation.id).catch(() => { });
1849
1985
  typingSignals.clear(conversation.id).catch(() => { });
1850
1986
  }
1851
1987
  console.error(`[canon-host] ${convos.length} conversations`);
@@ -1853,68 +1989,64 @@ export async function main() {
1853
1989
  catch {
1854
1990
  console.error('[canon-host] Could not load conversations');
1855
1991
  }
1856
- // ── Session manager (generic lifecycle from @canonmsg/agent-host) ──
1857
- const approvalManager = ownerId
1858
- ? new ApprovalManager(createPortApprovalClient(port), agentId, ownerId)
1859
- : null;
1860
- const lifecycle = createSessionLifecycle({
1861
- maxSessions: MAX_SESSIONS,
1862
- idleTimeoutMs: IDLE_TIMEOUT_MS,
1863
- heartbeatMs: HEARTBEAT_MS,
1864
- idleCheckMs: IDLE_CHECK_MS,
1865
- logPrefix: '[canon-host]',
1866
- }, {
1867
- knownConversationIds,
1868
- createSession: (conversationId) => createSessionForConversation(conversationId),
1869
- isSessionRunning: (session) => session.state.state === 'running',
1870
- writeTurnOnHeartbeat: (session) => session.state.state !== 'running' && session.state.state !== 'requires_action',
1871
- writeSessionState: (session) => session.writeState(),
1872
- writeSessionTurn: (session) => session.writeTurn(),
1873
- releaseEnvironment: (session) => releaseConversationEnvironment(session.environment),
1874
- persistResumeState,
1875
- disposeRuntime: (session) => session.query.close(),
1876
- interruptRuntime: (session) => session.query.interrupt(),
1877
- port,
1878
- publishRuntimeHeartbeat,
1879
- onShutdown: async () => {
1880
- detachControlNotifications();
1881
- notifications.detach();
1882
- approvalManager?.dispose();
1883
- await port.clearAgentRuntime().catch(() => { });
1884
- bridge.client.close();
1992
+ // ── Session manager ──
1993
+ const sessions = new Map();
1994
+ const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
1995
+ const pendingSessionCreations = new Map();
1996
+ // Shared /control channel poller (claude host profile): session + signal
1997
+ // keys, sequential conversations, active/idle cadence + jitter sampled at
1998
+ // cycle start, eager baselines at session creation, and nodes left in place
1999
+ // when a handler throws (consumeOnError defaults to false for both keys).
2000
+ const controlPoller = new ControlChannelPoller({
2001
+ rtdb,
2002
+ agentId,
2003
+ conversationIds: () => sessions.keys(),
2004
+ cadence: {
2005
+ kind: 'adaptive',
2006
+ activeMs: CONTROL_POLL_MS,
2007
+ idleMs: IDLE_CONTROL_POLL_MS,
2008
+ jitterMs: CONTROL_POLL_JITTER_MS,
2009
+ hasActiveWork: () => [...sessions.values()].some((session) => !session.closed
2010
+ && (session.state.state === 'running'
2011
+ || session.state.state === 'requires_action'
2012
+ || session.pendingInputs.length > 0
2013
+ || Boolean(session.pendingFinalDelivery))),
2014
+ activitySample: 'cycle-start',
1885
2015
  },
1886
- onShutdownComplete: () => {
1887
- markLocalRuntimeStopped(runtimeId);
1888
- lockHandle?.release();
2016
+ pollOnStart: true,
2017
+ conversationConcurrency: 'sequential',
2018
+ handlers: {
2019
+ session: { handle: applyRuntimeSessionControl },
2020
+ signal: { handle: handleRuntimeControlSignal },
2021
+ },
2022
+ onError: ({ scope, key, conversationId, error }) => {
2023
+ if (scope === 'read')
2024
+ return; // Transient RTDB errors — retry next cycle
2025
+ const convo = conversationId ? ` [${conversationId.slice(0, 8)}]` : '';
2026
+ console.error(`[canon-host]${convo} Control ${key ?? 'poll'} ${scope} error:`, error);
1889
2027
  },
1890
- });
1891
- const { sessions, getOrCreateSession, closeSession, resetRuntimeSession, markSessionPendingInputsRejected, removeQueuedInput, } = lifecycle;
1892
- // Interrupt/stop-clear signal handling (generic shell from @canonmsg/agent-host).
1893
- const handleRuntimeControlSignal = createRuntimeSignalHandler({
1894
- agentId,
1895
- logPrefix: '[canon-host]',
1896
- capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1897
- }, {
1898
- sessions,
1899
- port,
1900
- isSessionRunning: (session) => session.state.state === 'running',
1901
- interruptRuntime: (session) => session.query.interrupt(),
1902
- resetSession: (session) => resetRuntimeSession(session),
1903
- markSessionPendingInputsRejected,
1904
- });
1905
- // Control events arrive as bridge notifications — the bridge owns the ONE
1906
- // authoritative /control consumer for this identity (plan §2); the host's
1907
- // adaptive poller (active/idle cadence, jitter, per-session baselines) is
1908
- // deleted with it.
1909
- const detachControlNotifications = attachHostControlNotifications(notifications.source, { logPrefix: '[canon-host]' }, {
1910
- onSessionControl: applyRuntimeSessionControl,
1911
- onControlSignal: handleRuntimeControlSignal,
1912
2028
  });
1913
2029
  /** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
1914
2030
  const savedSessionIds = new Map();
2031
+ const { getConversationMeta } = createConversationMetadataLoader({
2032
+ client,
2033
+ conversationCache,
2034
+ });
1915
2035
  function resolveWorkspaceIdForBaseCwd(baseCwd) {
1916
2036
  return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
1917
2037
  }
2038
+ async function refreshKnownConversationIds(force = false) {
2039
+ if (!force && Date.now() - lastKnownConversationRefreshAt < HEARTBEAT_MS) {
2040
+ return;
2041
+ }
2042
+ const conversations = await client.getConversations();
2043
+ knownConversationIds.clear();
2044
+ for (const conversation of conversations) {
2045
+ knownConversationIds.add(conversation.id);
2046
+ conversationCache.set(conversation.id, conversation);
2047
+ }
2048
+ lastKnownConversationRefreshAt = Date.now();
2049
+ }
1918
2050
  async function canRequestCanonApproval(conversationId) {
1919
2051
  if (!ownerId)
1920
2052
  return false;
@@ -1922,9 +2054,80 @@ export async function main() {
1922
2054
  .catch(() => conversationCache.get(conversationId) ?? null);
1923
2055
  return Array.isArray(conversation?.memberIds) && conversation.memberIds.includes(ownerId);
1924
2056
  }
1925
- /** Resume-state persistence on session close (claude-specific: SDK sessionId). */
1926
- function persistResumeState(session, saveResume) {
1927
- const conversationId = session.conversationId;
2057
+ function handleConversationUpdated(payload) {
2058
+ const rawMemberIds = payload.changes.memberIds;
2059
+ if (!Array.isArray(rawMemberIds))
2060
+ return;
2061
+ const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
2062
+ const cached = conversationCache.get(payload.conversationId);
2063
+ const membershipChange = payload.membershipChange
2064
+ ?? (cached ? diffCanonMemberIds(cached.memberIds, memberIds) : null);
2065
+ if (cached) {
2066
+ conversationCache.set(payload.conversationId, {
2067
+ ...cached,
2068
+ memberIds,
2069
+ });
2070
+ }
2071
+ if (membershipChange) {
2072
+ pendingMembershipChanges.set(payload.conversationId, membershipChange);
2073
+ }
2074
+ if (!memberIds.includes(agentId)) {
2075
+ knownConversationIds.delete(payload.conversationId);
2076
+ conversationCache.delete(payload.conversationId);
2077
+ }
2078
+ }
2079
+ function getGroupContextMode(conversationId, conversation) {
2080
+ if (conversation?.type !== 'group')
2081
+ return undefined;
2082
+ if (pendingMembershipChanges.has(conversationId))
2083
+ return 'membership_change';
2084
+ if (!promptedGroupContextConversationIds.has(conversationId))
2085
+ return 'initial';
2086
+ return undefined;
2087
+ }
2088
+ function markGroupContextModeUsed(conversationId, mode) {
2089
+ if (!mode)
2090
+ return;
2091
+ promptedGroupContextConversationIds.add(conversationId);
2092
+ if (mode === 'membership_change') {
2093
+ pendingMembershipChanges.delete(conversationId);
2094
+ }
2095
+ }
2096
+ async function loadHydratedInboundContext(input) {
2097
+ const [conversation, page] = await Promise.all([
2098
+ getConversationMeta(input.conversationId),
2099
+ input.hydratedPage
2100
+ ? Promise.resolve(input.hydratedPage)
2101
+ : client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
2102
+ ]);
2103
+ return buildHydratedInboundContext({
2104
+ agentId,
2105
+ conversationId: input.conversationId,
2106
+ conversation,
2107
+ page,
2108
+ activeSelfContextId: input.activeSelfContextId,
2109
+ selfContexts: input.selfContexts,
2110
+ provenance: input.provenance,
2111
+ message: input.message,
2112
+ senderName: input.senderName,
2113
+ isOwner: input.isOwner,
2114
+ ownerId,
2115
+ ownerName,
2116
+ membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
2117
+ groupContextMode: getGroupContextMode(input.conversationId, conversation),
2118
+ });
2119
+ }
2120
+ function closeSession(conversationId, options = {}) {
2121
+ const session = sessions.get(conversationId);
2122
+ if (!session)
2123
+ return;
2124
+ const saveResume = options.saveResume !== false;
2125
+ session.closed = true;
2126
+ if (session.typingKeepaliveTimer) {
2127
+ clearInterval(session.typingKeepaliveTimer);
2128
+ session.typingKeepaliveTimer = null;
2129
+ }
2130
+ releaseConversationEnvironment(session.environment);
1928
2131
  const resumeKey = buildConversationEnvironmentKey(conversationId, session.environment.baseCwd);
1929
2132
  if (saveResume && session.sdkSessionId) {
1930
2133
  savedSessionIds.set(resumeKey, session.sdkSessionId);
@@ -1956,15 +2159,89 @@ export async function main() {
1956
2159
  });
1957
2160
  }
1958
2161
  }
2162
+ session.clearStreaming().catch(() => { });
2163
+ if (session.idleResetTimer) {
2164
+ clearTimeout(session.idleResetTimer);
2165
+ session.idleResetTimer = null;
2166
+ }
2167
+ if (session.finalDeliveryTimer) {
2168
+ clearTimeout(session.finalDeliveryTimer);
2169
+ session.finalDeliveryTimer = null;
2170
+ }
2171
+ runtimeState.clearSessionState(conversationId).catch(() => { });
2172
+ runtimeState.clearTurnState(conversationId).catch(() => { });
2173
+ typingSignals.clear(conversationId).catch(() => { });
2174
+ typingSignals.dispose(conversationId);
2175
+ try {
2176
+ session.query.close();
2177
+ }
2178
+ catch { }
2179
+ sessions.delete(conversationId);
2180
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${options.reason ?? (saveResume ? 'suspended (sessionId saved for resume)' : 'cleared')}`);
1959
2181
  }
1960
- /** Runtime-specific session construction, invoked via the lifecycle's getOrCreateSession. */
1961
- function createSessionForConversation(conversationId) {
1962
- return (async () => {
1963
- const config = await loadSessionConfig(port, conversationId);
1964
- const sessionSettings = resolveEffectiveSessionSettings({
1965
- profileDefaults: profileSessionDefaults,
1966
- sessionConfig: config,
1967
- });
2182
+ async function resetRuntimeSession(session) {
2183
+ const conversationId = session.conversationId;
2184
+ const droppedInputs = session.pendingInputs.splice(0);
2185
+ await markSessionPendingInputsRejected(conversationId, droppedInputs);
2186
+ if (session.activeInput) {
2187
+ session.interruptedTurnKeys.add(session.activeInput.turnKey);
2188
+ }
2189
+ session.pendingFinalText = null;
2190
+ session.pendingFinalDelivery = null;
2191
+ session.turnState = 'interrupted';
2192
+ session.clearStreaming().catch(() => { });
2193
+ typingSignals.clear(conversationId).catch(() => { });
2194
+ if (session.state.state === 'running') {
2195
+ await session.query.interrupt().catch(() => { });
2196
+ }
2197
+ closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
2198
+ }
2199
+ async function markSessionPendingInputsRejected(conversationId, inputs) {
2200
+ await Promise.all(inputs.map((input) => {
2201
+ if (!input.markAccepted || !input.sourceMessageId)
2202
+ return Promise.resolve();
2203
+ return client.updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected').catch(() => { });
2204
+ }));
2205
+ }
2206
+ function removeQueuedInput(conversationId, sourceMessageId) {
2207
+ const session = sessions.get(conversationId);
2208
+ if (!session || session.pendingInputs.length === 0)
2209
+ return;
2210
+ const before = session.pendingInputs.length;
2211
+ session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
2212
+ if (session.pendingInputs.length === before)
2213
+ return;
2214
+ session.writeTurn();
2215
+ }
2216
+ function evictOldestIdle() {
2217
+ let oldest = null;
2218
+ for (const s of sessions.values()) {
2219
+ if (s.state.state === 'running')
2220
+ continue; // Don't evict running sessions
2221
+ if (!oldest || s.lastActivity < oldest.lastActivity)
2222
+ oldest = s;
2223
+ }
2224
+ if (oldest) {
2225
+ console.error(`[canon-host] [${oldest.conversationId.slice(0, 8)}] Evicting idle session`);
2226
+ closeSession(oldest.conversationId);
2227
+ }
2228
+ }
2229
+ async function getOrCreateSession(conversationId) {
2230
+ knownConversationIds.add(conversationId);
2231
+ const existing = sessions.get(conversationId);
2232
+ if (existing && !existing.closed) {
2233
+ existing.lastActivity = Date.now();
2234
+ return existing;
2235
+ }
2236
+ const pending = pendingSessionCreations.get(conversationId);
2237
+ if (pending)
2238
+ return pending;
2239
+ // Evict if at capacity
2240
+ if (sessions.size >= MAX_SESSIONS) {
2241
+ evictOldestIdle();
2242
+ }
2243
+ const creation = (async () => {
2244
+ const config = await loadSessionConfig(conversationId, agentId);
1968
2245
  const sessionExecutionMode = resolveSessionExecutionMode(config);
1969
2246
  const workspaceCwd = resolveWorkspaceCwd(config);
1970
2247
  const environment = prepareConversationEnvironment({
@@ -1981,7 +2258,7 @@ export async function main() {
1981
2258
  executionMode: environment.mode,
1982
2259
  });
1983
2260
  const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
1984
- const session = createSession(conversationId, environment, agentId, port, typingSignals, (convoId, sdkSessionId) => {
2261
+ const session = createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, (convoId, sdkSessionId) => {
1985
2262
  // Called when session processing loop ends — save session ID for resume
1986
2263
  const existing = sessions.get(convoId);
1987
2264
  if (existing) {
@@ -2005,17 +2282,17 @@ export async function main() {
2005
2282
  }
2006
2283
  existing?.clearStreaming().catch(() => { });
2007
2284
  typingSignals.clear(convoId).catch(() => { });
2285
+ typingSignals.dispose(convoId);
2008
2286
  sessions.delete(convoId);
2009
- port.clearSessionState({ conversationId: convoId }).catch(() => { });
2010
- port.clearTurnState({ conversationId: convoId }).catch(() => { });
2287
+ runtimeState.clearSessionState(convoId).catch(() => { });
2288
+ runtimeState.clearTurnState(convoId).catch(() => { });
2011
2289
  }, (descriptor) => {
2012
2290
  runtimeDescriptor = descriptor;
2013
2291
  void publishRuntimeHeartbeat();
2014
2292
  }, {
2015
- model: sessionSettings.model ?? runtimeModels[0]?.value,
2293
+ model: config?.model ?? runtimeModels[0]?.value,
2016
2294
  permissionMode: config?.permissionMode ?? 'default',
2017
- effort: sessionSettings.effort,
2018
- sessionSettings,
2295
+ effort: config?.effort,
2019
2296
  availableModels: runtimeModels,
2020
2297
  availableExecutionModes: hostAvailableExecutionModes,
2021
2298
  runtimePresentation,
@@ -2025,6 +2302,7 @@ export async function main() {
2025
2302
  ownerId,
2026
2303
  }, resumeId);
2027
2304
  sessions.set(conversationId, session);
2305
+ await controlPoller.baseline([conversationId]);
2028
2306
  return session;
2029
2307
  }
2030
2308
  catch (error) {
@@ -2032,6 +2310,13 @@ export async function main() {
2032
2310
  throw error;
2033
2311
  }
2034
2312
  })();
2313
+ pendingSessionCreations.set(conversationId, creation);
2314
+ try {
2315
+ return await creation;
2316
+ }
2317
+ finally {
2318
+ pendingSessionCreations.delete(conversationId);
2319
+ }
2035
2320
  }
2036
2321
  async function enqueueRecoveredInboundMessage(input) {
2037
2322
  const m = input.message;
@@ -2088,9 +2373,7 @@ export async function main() {
2088
2373
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
2089
2374
  console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
2090
2375
  await markQueuedMessageAcceptedForConversation(input.conversationId, m.id ?? null, shouldMarkAccepted);
2091
- await port.sendMessage({
2092
- conversationId: input.conversationId,
2093
- text: `I couldn't start a coding session for this workspace: ${userMessage}`,
2376
+ await sendMessageWithRetry(client, input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
2094
2377
  messageId: `claude-start-failed-${m.id}`,
2095
2378
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2096
2379
  metadata: {
@@ -2133,261 +2416,332 @@ export async function main() {
2133
2416
  });
2134
2417
  }
2135
2418
  }
2136
- try {
2137
- for (const conversation of conversationCache.values()) {
2138
- const cursor = loadRuntimeSessionState(runtimeId, {
2139
- conversationId: conversation.id,
2140
- baseCwd: workingDir,
2141
- })?.lastInboundMessageId;
2142
- const recovery = await collectMissedInboundMessages({
2143
- fetchPage: (before) => port.getMessages({
2144
- conversationId: conversation.id,
2145
- limit: STARTUP_RECOVERY_PAGE_SIZE,
2146
- ...(before ? { before } : {}),
2147
- includeBehavior: true,
2148
- }),
2149
- cursor,
2150
- agentId,
2151
- });
2152
- if (recovery.mode === 'truncated-window') {
2153
- console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Startup recovery cursor not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; recovering truncated window`);
2154
- }
2155
- if (recovery.messages.length > 1) {
2156
- console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Recovered ${recovery.messages.length} missed messages in ${conversation.id}`);
2157
- }
2158
- for (const message of recovery.messages) {
2159
- console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Recovering inbound message on startup`);
2160
- await enqueueRecoveredInboundMessage({
2161
- conversationId: conversation.id,
2162
- message: message,
2163
- hydratedPage: recovery.newestPage,
2164
- });
2165
- }
2166
- }
2167
- }
2168
- catch (error) {
2169
- console.error('[canon-host] Failed startup recovery:', error instanceof Error ? error.message : error);
2170
- }
2171
- // ── Inbound Canon events (bridge notifications) ──
2172
- // The bridge owns SSE reconnect/heartbeat/replay + dedup + self-filtering;
2173
- // HITL control-reply messages (approval_reply / plan_approval_reply) are
2174
- // STRIPPED from onMessage bridge-side — unmatched ones arrive as
2175
- // onControlReply and feed the plan-approval waiters below.
2176
- const handleInboundMessage = (payload) => {
2177
- const m = payload.message;
2178
- if (m.senderId === agentId)
2179
- return;
2180
- const convoId = payload.conversationId;
2181
- knownConversationIds.add(convoId);
2182
- const sender = m.senderName || m.senderId;
2183
- const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2184
- if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2185
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2186
- return;
2187
- }
2188
- void (async () => {
2189
- let materialized = [];
2190
- if (m.id) {
2191
- try {
2192
- materialized = await materializeMessageMedia({
2193
- id: m.id,
2194
- attachments: m.attachments ?? [],
2195
- }, { agentId, conversationId: convoId });
2419
+ // ── Connect SSE stream for inbound Canon messages ──
2420
+ const stream = new CanonStream({
2421
+ apiKey,
2422
+ agentId,
2423
+ handler: {
2424
+ onMessage: (payload) => {
2425
+ const m = payload.message;
2426
+ if (m.senderId === agentId)
2427
+ return;
2428
+ const convoId = payload.conversationId;
2429
+ knownConversationIds.add(convoId);
2430
+ if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
2431
+ const consumed = approvalManager?.handleMessage(convoId, {
2432
+ senderId: m.senderId,
2433
+ metadata: m.metadata,
2434
+ }) ?? false;
2435
+ if (m.metadata?.type === 'approval_reply') {
2436
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
2437
+ return;
2438
+ }
2439
+ if (consumed) {
2440
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
2441
+ return;
2442
+ }
2196
2443
  }
2197
- catch (error) {
2198
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
2444
+ // Check for metadata replies (plan approvals).
2445
+ if (m.metadata?.type === 'plan_approval_reply') {
2446
+ const existingSession = sessions.get(convoId);
2447
+ const pendingReply = existingSession?.pendingReply;
2448
+ const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2449
+ const replyRequestId = m.metadata.planId;
2450
+ if (!pendingReply) {
2451
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
2452
+ return;
2453
+ }
2454
+ if (!isOwnerReply) {
2455
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
2456
+ return;
2457
+ }
2458
+ if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
2459
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
2460
+ return;
2461
+ }
2462
+ if (existingSession?.pendingReply) {
2463
+ const { resolve } = existingSession.pendingReply;
2464
+ existingSession.pendingReply = null;
2465
+ existingSession.lastActivity = Date.now();
2466
+ resolve(m.metadata);
2467
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
2468
+ return;
2469
+ }
2199
2470
  }
2200
- }
2201
- const content = renderInboundContent(m, materialized);
2202
- const hydrated = await loadHydratedInboundContext({
2203
- conversationId: convoId,
2204
- message: m,
2205
- senderName: sender,
2206
- isOwner,
2207
- activeSelfContextId: payload.activeSelfContextId,
2208
- selfContexts: payload.selfContexts,
2209
- provenance: payload.provenance,
2210
- });
2211
- const behavior = payload.behavior ?? hydrated.behavior;
2212
- const activeSelfContextId = hydrated.activeSelfContextId;
2213
- const selfContexts = hydrated.selfContexts;
2214
- const replyMedia = await materializePromptReplyContext({
2215
- replyContext: hydrated.replyContext,
2216
- agentId,
2217
- conversationId: convoId,
2218
- logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
2219
- });
2220
- const replyContext = replyMedia.replyContext;
2221
- const participantContext = hydrated.participantContext;
2222
- const autoReply = payload.turnDispatch?.kind === 'run_turn'
2223
- ? {
2224
- allow: true,
2225
- reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
2471
+ const sender = m.senderName || m.senderId;
2472
+ const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2473
+ if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2474
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2475
+ return;
2226
2476
  }
2227
- : decideAutoReply(participantContext, behavior);
2228
- if (!autoReply.allow) {
2229
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
2230
- return;
2231
- }
2232
- markGroupContextModeUsed(convoId, participantContext.groupContextMode);
2233
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
2234
- const turnMetadata = normalizeTurnMetadata(m.metadata);
2235
- const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
2236
- const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
2237
- let session;
2477
+ void (async () => {
2478
+ let materialized = [];
2479
+ if (m.id) {
2480
+ try {
2481
+ materialized = await materializeMessageMedia({
2482
+ id: m.id,
2483
+ attachments: m.attachments ?? [],
2484
+ }, { agentId, conversationId: convoId });
2485
+ }
2486
+ catch (error) {
2487
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
2488
+ }
2489
+ }
2490
+ const content = renderInboundContent(m, materialized);
2491
+ const hydrated = await loadHydratedInboundContext({
2492
+ conversationId: convoId,
2493
+ message: m,
2494
+ senderName: sender,
2495
+ isOwner,
2496
+ activeSelfContextId: payload.activeSelfContextId,
2497
+ selfContexts: payload.selfContexts,
2498
+ provenance: payload.provenance,
2499
+ });
2500
+ const behavior = payload.behavior ?? hydrated.behavior;
2501
+ const activeSelfContextId = hydrated.activeSelfContextId;
2502
+ const selfContexts = hydrated.selfContexts;
2503
+ const replyMedia = await materializePromptReplyContext({
2504
+ replyContext: hydrated.replyContext,
2505
+ agentId,
2506
+ conversationId: convoId,
2507
+ logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
2508
+ });
2509
+ const replyContext = replyMedia.replyContext;
2510
+ const participantContext = hydrated.participantContext;
2511
+ const autoReply = payload.turnDispatch?.kind === 'run_turn'
2512
+ ? {
2513
+ allow: true,
2514
+ reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
2515
+ }
2516
+ : decideAutoReply(participantContext, behavior);
2517
+ if (!autoReply.allow) {
2518
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
2519
+ return;
2520
+ }
2521
+ markGroupContextModeUsed(convoId, participantContext.groupContextMode);
2522
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
2523
+ const turnMetadata = normalizeTurnMetadata(m.metadata);
2524
+ const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
2525
+ const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
2526
+ let session;
2527
+ try {
2528
+ session = await getOrCreateSession(convoId);
2529
+ }
2530
+ catch (error) {
2531
+ const message = error instanceof Error ? error.message : String(error);
2532
+ const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
2533
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
2534
+ await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
2535
+ await sendMessageWithRetry(client, convoId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
2536
+ messageId: `claude-start-failed-${m.id}`,
2537
+ ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2538
+ metadata: {
2539
+ turnSemantics: 'turn_complete',
2540
+ replyBehavior: 'suppress_auto_reply',
2541
+ },
2542
+ }).catch(() => { });
2543
+ return;
2544
+ }
2545
+ session.activeSelfContextId = activeSelfContextId;
2546
+ const promptText = buildCanonPrompt({
2547
+ content,
2548
+ conversationId: convoId,
2549
+ participantContext,
2550
+ behavior,
2551
+ selfContexts,
2552
+ activeSelfContextId,
2553
+ provenance: hydrated.provenance,
2554
+ replyContext,
2555
+ message: m,
2556
+ });
2557
+ const messageContent = await buildCanonUserContent({
2558
+ promptText,
2559
+ materialized: [...replyMedia.materialized, ...materialized],
2560
+ });
2561
+ const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
2562
+ session.enqueueInbound({
2563
+ type: 'user',
2564
+ message: {
2565
+ role: 'user',
2566
+ content: messageContent,
2567
+ },
2568
+ parent_tool_use_id: null,
2569
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
2570
+ if (m.id) {
2571
+ saveRuntimeSessionState(runtimeId, {
2572
+ conversationId: convoId,
2573
+ baseCwd: workingDir,
2574
+ lastInboundMessageId: m.id,
2575
+ });
2576
+ }
2577
+ })();
2578
+ },
2579
+ onMessageDeleted: (payload) => {
2580
+ removeQueuedInput(payload.conversationId, payload.messageId);
2581
+ },
2582
+ onConversationUpdated: (payload) => {
2583
+ handleConversationUpdated(payload);
2584
+ },
2585
+ onConnected: () => {
2586
+ streamConnected = true;
2587
+ void publishRuntimeHeartbeat();
2588
+ console.error('[canon-host] SSE connected');
2589
+ },
2590
+ onDisconnected: () => {
2591
+ streamConnected = false;
2592
+ runtimeState.clearAgentRuntime().catch(() => { });
2593
+ console.error('[canon-host] SSE disconnected');
2594
+ },
2595
+ onError: (err) => console.error(`[canon-host] SSE error: ${err.message}`),
2596
+ },
2597
+ });
2598
+ stream.start().catch((err) => {
2599
+ console.error('[canon-host] SSE start error:', err instanceof Error ? err.message : err);
2600
+ });
2601
+ // ── Control signal watcher ──
2602
+ // Session control signals (model, mode, effort). A missing/closed session
2603
+ // leaves the node in place; lastSeen still advances, so a stale control is
2604
+ // never replayed once a session exists.
2605
+ async function applyRuntimeSessionControl(event) {
2606
+ const convoId = event.conversationId;
2607
+ const session = sessions.get(convoId);
2608
+ if (!session || session.closed)
2609
+ return { consume: false };
2610
+ const ctrl = event.control;
2611
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
2612
+ if (ctrl.model && ctrl.model !== session.state.model) {
2238
2613
  try {
2239
- session = await getOrCreateSession(convoId);
2614
+ await session.query.setModel(ctrl.model);
2615
+ session.state.model = ctrl.model;
2616
+ session.clearRuntimeControlError('model');
2617
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Model → ${ctrl.model}`);
2240
2618
  }
2241
- catch (error) {
2242
- const message = error instanceof Error ? error.message : String(error);
2243
- const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
2244
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
2245
- await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
2246
- await port.sendMessage({
2247
- conversationId: convoId,
2248
- text: `I couldn't start a coding session for this workspace: ${userMessage}`,
2249
- messageId: `claude-start-failed-${m.id}`,
2250
- ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2251
- metadata: {
2252
- turnSemantics: 'turn_complete',
2253
- replyBehavior: 'suppress_auto_reply',
2254
- },
2255
- }).catch(() => { });
2256
- return;
2257
- }
2258
- session.activeSelfContextId = activeSelfContextId;
2259
- const promptText = buildCanonPrompt({
2260
- content,
2261
- conversationId: convoId,
2262
- participantContext,
2263
- behavior,
2264
- selfContexts,
2265
- activeSelfContextId,
2266
- provenance: hydrated.provenance,
2267
- replyContext,
2268
- message: m,
2269
- });
2270
- const messageContent = await buildCanonUserContent({
2271
- promptText,
2272
- materialized: [...replyMedia.materialized, ...materialized],
2273
- });
2274
- const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
2275
- session.enqueueInbound({
2276
- type: 'user',
2277
- message: {
2278
- role: 'user',
2279
- content: messageContent,
2280
- },
2281
- parent_tool_use_id: null,
2282
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
2283
- if (m.id) {
2284
- saveRuntimeSessionState(runtimeId, {
2285
- conversationId: convoId,
2286
- baseCwd: workingDir,
2287
- lastInboundMessageId: m.id,
2288
- });
2289
- }
2290
- })();
2291
- };
2292
- /**
2293
- * Stripped HITL control replies that matched no bridge record: the plan
2294
- * approval flow (host-raised via a plain sendMessage) resolves its
2295
- * metadata-reply waiter here; anything else is fed to the approval
2296
- * manager as a late/second-chance consume.
2297
- */
2298
- const handleControlReply = (convoId, m) => {
2299
- if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
2300
- const consumed = approvalManager?.handleMessage(convoId, {
2301
- senderId: m.senderId,
2302
- metadata: m.metadata,
2303
- }) ?? false;
2304
- if (m.metadata?.type === 'approval_reply') {
2305
- console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
2306
- return;
2307
- }
2308
- if (consumed) {
2309
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
2310
- return;
2619
+ catch (err) {
2620
+ session.setRuntimeControlError('model', ctrl.model, err);
2621
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
2311
2622
  }
2312
2623
  }
2313
- if (m.metadata?.type === 'plan_approval_reply') {
2314
- const existingSession = sessions.get(convoId);
2315
- const pendingReply = existingSession?.pendingReply;
2316
- const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2317
- const replyRequestId = m.metadata.planId;
2318
- if (!pendingReply) {
2319
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
2320
- return;
2321
- }
2322
- if (!isOwnerReply) {
2323
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
2324
- return;
2325
- }
2326
- if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
2327
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
2328
- return;
2624
+ const nextPermissionMode = parseClaudePermissionMode(ctrl.permissionMode);
2625
+ if (ctrl.permissionMode && !nextPermissionMode) {
2626
+ session.setRuntimeControlError('permissionMode', ctrl.permissionMode, new Error(`Unsupported Claude permission mode: ${ctrl.permissionMode}`));
2627
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Unsupported mode: ${ctrl.permissionMode}`);
2628
+ }
2629
+ else if (nextPermissionMode && nextPermissionMode !== session.state.permissionMode) {
2630
+ try {
2631
+ await session.query.setPermissionMode(nextPermissionMode);
2632
+ session.state.permissionMode = nextPermissionMode;
2633
+ session.clearRuntimeControlError('permissionMode');
2634
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${nextPermissionMode}`);
2329
2635
  }
2330
- if (existingSession?.pendingReply) {
2331
- const { resolve } = existingSession.pendingReply;
2332
- existingSession.pendingReply = null;
2333
- existingSession.lastActivity = Date.now();
2334
- resolve(m.metadata);
2335
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
2636
+ catch (err) {
2637
+ session.setRuntimeControlError('permissionMode', nextPermissionMode, err);
2638
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
2336
2639
  }
2337
2640
  }
2338
- };
2339
- notifications.attach({
2340
- onMessage: (params) => {
2341
- handleInboundMessage(params);
2342
- },
2343
- onMessageDeleted: (params) => {
2344
- const payload = params;
2345
- removeQueuedInput(payload.conversationId, payload.messageId);
2346
- },
2347
- onConversationUpdated: (params) => {
2348
- handleConversationUpdated(params);
2349
- },
2350
- onControlReply: (params) => {
2351
- const payload = params;
2352
- handleControlReply(payload.conversationId, payload.message);
2353
- },
2354
- ready: () => {
2355
- streamConnected = true;
2356
- void publishRuntimeHeartbeat();
2357
- console.error('[canon-host] Bridge ready');
2358
- },
2359
- connectionState: (params) => {
2360
- const { upstream } = params;
2361
- if (upstream === 'connected') {
2362
- streamConnected = true;
2363
- void publishRuntimeHeartbeat();
2364
- console.error('[canon-host] Canon upstream connected');
2641
+ const nextEffort = normalizeClaudeEffortLevel(ctrl.effort);
2642
+ if (nextEffort && nextEffort !== session.state.effort) {
2643
+ try {
2644
+ await applyClaudeEffortLevel(session.query, nextEffort);
2645
+ session.state.effort = nextEffort;
2646
+ session.clearRuntimeControlError('effort');
2647
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Effort → ${nextEffort}`);
2365
2648
  }
2366
- else {
2367
- streamConnected = false;
2368
- port.clearAgentRuntime().catch(() => { });
2369
- console.error(`[canon-host] Canon upstream ${upstream}`);
2649
+ catch (err) {
2650
+ session.setRuntimeControlError('effort', nextEffort, err);
2651
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err);
2370
2652
  }
2371
- },
2372
- });
2373
- // ── Control signal watcher ──
2374
- // Session control signals (model, mode, effort). The bridge has already
2375
- // consumed the control notification, so every path must publish an answer.
2376
- async function applyRuntimeSessionControl(event) {
2653
+ }
2654
+ else if (ctrl.effort && !nextEffort) {
2655
+ session.setRuntimeControlError('effort', String(ctrl.effort), new Error('Unsupported Claude thinking level.'));
2656
+ }
2657
+ session.writeState();
2658
+ }
2659
+ // Interrupt/stop-clear signal. Throwing leaves the node in place
2660
+ // (consumeOnError: false); lastSeen has already advanced, so the failed
2661
+ // signal is reported but never retried.
2662
+ async function handleRuntimeControlSignal(event) {
2377
2663
  const convoId = event.conversationId;
2378
2664
  const session = sessions.get(convoId);
2379
- if (!session || session.closed) {
2380
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Control received without live session; publishing persisted snapshot`);
2381
- void publishSessionSnapshotsForConversations([convoId]);
2665
+ if (!session || session.closed)
2666
+ return { consume: false };
2667
+ if (event.type === 'new_session') {
2668
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
2669
+ await resetRuntimeSession(session);
2382
2670
  return;
2383
2671
  }
2384
- const ctrl = event.control;
2385
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
2386
- applyClaudeSessionControl(session, ctrl);
2672
+ const isActive = session.state.state === 'running';
2673
+ if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
2674
+ return; // Nothing to interrupt — consume the stale signal
2675
+ }
2676
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
2677
+ if (event.type === 'stop_and_drop') {
2678
+ const droppedInputs = session.pendingInputs.splice(0);
2679
+ await markSessionPendingInputsRejected(convoId, droppedInputs);
2680
+ }
2681
+ if (session.activeInput) {
2682
+ session.interruptedTurnKeys.add(session.activeInput.turnKey);
2683
+ }
2684
+ session.pendingFinalText = null;
2685
+ session.turnState = 'interrupted';
2686
+ session.clearStreaming().catch(() => { });
2687
+ typingSignals.clear(convoId).catch(() => { });
2688
+ runtimeState.writeTurnState(convoId, {
2689
+ turnId: session.currentTurnId,
2690
+ state: 'interrupted',
2691
+ queueDepth: session.pendingInputs.length,
2692
+ currentSpeakerId: agentId,
2693
+ lastAcceptedIntent: session.lastAcceptedIntent,
2694
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
2695
+ ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
2696
+ completedAt: { '.sv': 'timestamp' },
2697
+ }).catch(() => { });
2698
+ if (isActive) {
2699
+ await session.query.interrupt();
2700
+ }
2387
2701
  }
2388
- // ── Heartbeat + idle cleanup + graceful shutdown (agent-host lifecycle) ──
2389
- lifecycle.startTimers();
2390
- lifecycle.installSignalHandlers();
2702
+ controlPoller.start();
2703
+ // ── Heartbeat (per-session) ──
2704
+ const heartbeat = setInterval(() => {
2705
+ for (const session of sessions.values()) {
2706
+ session.writeState();
2707
+ if (session.state.state !== 'running' && session.state.state !== 'requires_action') {
2708
+ session.writeTurn();
2709
+ }
2710
+ }
2711
+ void publishRuntimeHeartbeat();
2712
+ }, HEARTBEAT_MS);
2713
+ // ── Idle session cleanup ──
2714
+ const idleCheck = setInterval(() => {
2715
+ const now = Date.now();
2716
+ for (const convoId of [...sessions.keys()]) {
2717
+ const session = sessions.get(convoId);
2718
+ if (!session || session.state.state === 'running')
2719
+ continue;
2720
+ if (now - session.lastActivity > IDLE_TIMEOUT_MS) {
2721
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Idle timeout`);
2722
+ closeSession(convoId);
2723
+ }
2724
+ }
2725
+ }, IDLE_CHECK_MS);
2726
+ // ── Graceful shutdown ──
2727
+ const shutdown = async () => {
2728
+ console.error('[canon-host] Shutting down...');
2729
+ controlPoller.stop();
2730
+ clearInterval(heartbeat);
2731
+ clearInterval(idleCheck);
2732
+ stream.stop();
2733
+ approvalManager?.dispose();
2734
+ await runtimeState.clearAgentRuntime().catch(() => { });
2735
+ for (const convoId of [...sessions.keys()]) {
2736
+ closeSession(convoId);
2737
+ }
2738
+ markLocalRuntimeStopped(runtimeId);
2739
+ (lockHandle ?? getActiveProfileLock())?.release();
2740
+ process.exit(0);
2741
+ };
2742
+ process.on('SIGINT', shutdown);
2743
+ process.on('SIGTERM', shutdown);
2744
+ process.on('SIGHUP', shutdown);
2391
2745
  console.error('[canon-host] Ready — sessions created on demand');
2392
2746
  // Keep alive
2393
2747
  await new Promise(() => { });
@@ -2396,7 +2750,7 @@ export async function main() {
2396
2750
  runCli(import.meta.url, main, (err) => {
2397
2751
  const message = err instanceof Error ? err.message : String(err);
2398
2752
  console.error(`[canon-host] ${message}`);
2399
- activeLockHandle?.release();
2753
+ getActiveProfileLock()?.release();
2400
2754
  process.exit(1);
2401
2755
  }, {
2402
2756
  name: 'canon-claude',