@canonmsg/claude-code-plugin 0.21.2 → 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 }
@@ -1099,16 +1050,14 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1099
1050
  cwd,
1100
1051
  environment,
1101
1052
  query: q,
1102
- controlApplySeq: new Map(),
1103
1053
  sendInput,
1104
1054
  enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactRoutingMode),
1105
- state: buildClaudeInitialSessionState({
1106
- model: config.model,
1055
+ state: {
1056
+ model: undefined,
1107
1057
  permissionMode: config.permissionMode,
1108
- effort: config.effort,
1109
- fallbackModel: config.sessionSettings.fallbackModel,
1110
- runtimeControlValues: config.sessionSettings.runtimeControlValues,
1111
- }),
1058
+ effort: undefined,
1059
+ state: 'idle',
1060
+ },
1112
1061
  availableModels: config.availableModels,
1113
1062
  streamingText: '',
1114
1063
  streamingTimer: null,
@@ -1140,92 +1089,155 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1140
1089
  setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
1141
1090
  clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
1142
1091
  };
1143
- // ── Per-session RTDB writers (generic skeleton from @canonmsg/agent-host) ──
1144
- const writers = createRuntimeWriters({
1145
- conversationId,
1146
- agentId,
1147
- capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1148
- fallbackTurnId: `claude-${conversationId}`,
1149
- streamingThrottleMs: STREAMING_THROTTLE_MS,
1150
- requestedControls: {},
1151
- }, {
1152
- session: () => session,
1153
- port,
1154
- buildSessionStateExtras: (writerSession) => ({
1155
- cwd,
1156
- executionMode: writerSession.environment.mode,
1157
- ...(writerSession.environment.branch ? { executionBranch: writerSession.environment.branch } : {}),
1158
- ...(writerSession.environment.worktreePath ? { worktreePath: writerSession.environment.worktreePath } : {}),
1159
- ...(resolveExecutionFallbackReason(writerSession.environment)
1160
- ? { executionFallbackReason: resolveExecutionFallbackReason(writerSession.environment) ?? undefined }
1161
- : {}),
1162
- }),
1163
- 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
+ },
1164
1114
  });
1165
- const { streamingOutput, flushStreaming, onStreamDelta, clearStreaming, getFinalTurnTrail, writeState, writeTurn, setRuntimeControlError, clearRuntimeControlError, refreshVisibleWorkSignal, startVisibleWorkSignal, stopVisibleWorkSignal, } = writers;
1166
- // Initial values come from persisted session config and are effective for
1167
- // this query. Publish immediately so controls do not linger as requested.
1168
- 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
+ }
1169
1126
  function isActiveTurnInterrupted() {
1170
1127
  const turnKey = session.activeInput?.turnKey;
1171
1128
  return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
1172
1129
  }
1173
- // ── Turn queue + final delivery (generic skeleton from @canonmsg/agent-host) ──
1174
- const turnQueue = createTurnQueue({ conversationId }, {
1175
- session: () => session,
1176
- isSessionRunning: (queueSession) => queueSession.state.state === 'running',
1177
- markSessionRunning: (queueSession) => { queueSession.state.state = 'running'; },
1178
- prepareInput: (input) => prepareArtifactsForInput(input),
1179
- sendInput: (input) => sendInput(input),
1180
- interruptRuntime: (queueSession) => queueSession.query.interrupt(),
1181
- port,
1182
- writers: {
1183
- writeTurn,
1184
- stopVisibleWorkSignal,
1185
- clearStreaming,
1186
- },
1187
- });
1188
- const { startCanonInput, drainPendingInput, markQueuedMessageAccepted } = turnQueue;
1189
- const finalDelivery = createFinalDelivery({
1190
- conversationId,
1191
- logPrefix: '[canon-host]',
1192
- delivery: { mode: 'retry', retryMs: FINAL_DELIVERY_RETRY_MS, maxRetries: MAX_FINAL_DELIVERY_RETRIES },
1193
- }, {
1194
- session: () => session,
1195
- port,
1196
- typingSignals,
1197
- writers: {
1198
- writeState,
1199
- writeTurn,
1200
- stopVisibleWorkSignal,
1201
- clearStreaming,
1202
- getFinalTurnTrail,
1203
- replaceStreamingSnapshot: (text, status) => streamingOutput.replaceSnapshot(text, status),
1204
- },
1205
- resetCompletedTurnState: (deliverySession) => resetClaudeCompletedTurnState(deliverySession),
1206
- 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({
1207
1194
  turn,
1208
- finalizedTurnKeys: deliverySession.finalizedTurnKeys,
1209
- interruptedTurnKeys: deliverySession.interruptedTurnKeys,
1210
- }),
1211
- buildFinalMessageId: (turnKey) => buildClaudeFinalMessageId({ agentId, conversationId, turnKey }),
1212
- drainPendingInput: () => drainPendingInput(),
1213
- });
1214
- const { clearIdleResetTimer, clearFinalDeliveryTimer, resetTurnToIdle, scheduleFinalHandoffReset, markPendingFinalDelivery, deliverFinalReply, scheduleFinalDeliveryRetry, } = finalDelivery;
1215
- async function sendTurnArtifactFile(file) {
1216
- // Byte transfer is bridge-owned (§B8): hand the bridge a path, it streams
1217
- // the upload; then send the permanent message with the attachment ref.
1218
- const uploaded = await port.uploadMedia({
1219
- path: file.path,
1220
- mime: inferUploadMimeType(file.path),
1221
- 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,
1222
1205
  conversationId,
1206
+ turnKey: deliverTurn.turnKey,
1223
1207
  });
1224
- return port.sendMessage({
1225
- conversationId,
1226
- text: '',
1227
- contentType: uploaded.attachment.kind,
1228
- 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, '', {
1229
1241
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1230
1242
  metadata: buildArtifactMediaMetadata(),
1231
1243
  });
@@ -1262,6 +1274,138 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1262
1274
  await routeWorkspaceGeneratedArtifacts(input);
1263
1275
  }
1264
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
+ }
1265
1409
  async function prepareArtifactsForInput(input) {
1266
1410
  if (input.kind !== 'canon')
1267
1411
  return;
@@ -1277,8 +1421,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1277
1421
  return;
1278
1422
  }
1279
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
+ }
1280
1440
  enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
1281
- turnQueue.enqueueCanonInput(createClaudeInputEnvelope({
1441
+ session.lastActivity = Date.now();
1442
+ const input = createClaudeInputEnvelope({
1282
1443
  kind: 'canon',
1283
1444
  msg,
1284
1445
  intent,
@@ -1286,11 +1447,35 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1286
1447
  markAccepted,
1287
1448
  isOwnerTurn,
1288
1449
  artifactRoutingMode,
1289
- }));
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);
1290
1476
  };
1291
1477
  writeTurn();
1292
1478
  // ── Process SDK output messages ──
1293
- let assistantStreamOrdinal = 0;
1294
1479
  const processMessages = async () => {
1295
1480
  try {
1296
1481
  for await (const msg of q) {
@@ -1321,10 +1506,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1321
1506
  stopVisibleWorkSignal();
1322
1507
  break;
1323
1508
  }
1324
- if (event?.type === 'message_start') {
1325
- assistantStreamOrdinal += 1;
1326
- }
1327
- 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') {
1328
1510
  session.toolInProgress = true;
1329
1511
  session.turnState = 'tool';
1330
1512
  streamingOutput.addBlock({
@@ -1353,35 +1535,12 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1353
1535
  stopVisibleWorkSignal();
1354
1536
  writeTurn();
1355
1537
  const blockIndex = typeof event.index === 'number' ? event.index : 'latest';
1356
- const messageOrdinal = assistantStreamOrdinal || 1;
1357
- onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${messageOrdinal}:${blockIndex}`);
1538
+ onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${blockIndex}`);
1358
1539
  }
1359
1540
  break;
1360
1541
  }
1361
1542
  case 'system': {
1362
1543
  const subtype = msg.subtype;
1363
- if (subtype === 'task_started'
1364
- || subtype === 'task_progress'
1365
- || subtype === 'task_updated'
1366
- || subtype === 'task_notification') {
1367
- const activity = mapClaudeSubagentActivity(msg);
1368
- if (activity && !isActiveTurnInterrupted()) {
1369
- if (!session.currentTurnId) {
1370
- session.currentTurnId = randomUUID();
1371
- session.currentTurnOpenedAt = Date.now();
1372
- }
1373
- if (session.state.state === 'running' && session.turnState !== 'streaming') {
1374
- session.turnState = 'thinking';
1375
- startVisibleWorkSignal();
1376
- writeTurn();
1377
- }
1378
- await streamingOutput.addBlock(activity).catch(() => { });
1379
- }
1380
- }
1381
- if (subtype === 'model_refusal_fallback') {
1382
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Model refusal fallback: `
1383
- + `${msg.original_model} → ${msg.fallback_model}`);
1384
- }
1385
1544
  if (subtype === 'session_state_changed') {
1386
1545
  const state = msg.state;
1387
1546
  session.state.state = state;
@@ -1393,7 +1552,6 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1393
1552
  }
1394
1553
  session.state.contextUsage = undefined;
1395
1554
  session.pendingFinalText = null;
1396
- assistantStreamOrdinal = 0;
1397
1555
  session.toolInProgress = false;
1398
1556
  session.turnState = 'thinking';
1399
1557
  startVisibleWorkSignal();
@@ -1490,6 +1648,24 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1490
1648
  const init = async () => {
1491
1649
  processMessages().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] processMessages crashed:`, err));
1492
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
+ }
1493
1669
  if (!resumeSessionId) {
1494
1670
  // New session — send seed message to activate streaming input mode
1495
1671
  sendInput(createClaudeInputEnvelope({
@@ -1504,32 +1680,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1504
1680
  },
1505
1681
  }));
1506
1682
  }
1507
- // Refresh the canonical runtime descriptor from a neutral probe. The live
1508
- // session may be initialized with a selected model, which can narrow the
1509
- // SDK's cached model list.
1683
+ // Refresh the canonical runtime descriptor after Claude reports supported models.
1510
1684
  try {
1511
- const discoveredModels = await detectRuntimeModels(cwd).catch((error) => {
1512
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Model discovery failed:`, error);
1513
- return runtimeModels;
1514
- });
1515
- if (discoveredModels.length > 0) {
1516
- runtimeModels = discoveredModels;
1517
- }
1518
- const modelList = resolveClaudeModelOptions({
1519
- discovered: runtimeModels,
1685
+ const models = await q.supportedModels();
1686
+ const modelList = mergeClaudeModelOptions({
1687
+ previous: runtimeModels,
1688
+ next: toModelOptions(models),
1520
1689
  selectedModel: session.state.model,
1521
1690
  });
1522
1691
  session.availableModels = modelList;
1692
+ runtimeModels = modelList;
1523
1693
  onRuntimeDescriptorUpdate({
1524
1694
  defaultWorkspaceId: workspaceOptions[0]?.id,
1525
- defaultModel: config.sessionSettings.model ?? modelList[0]?.value,
1695
+ defaultModel: modelList[0]?.value,
1526
1696
  defaultPermissionMode: 'default',
1527
1697
  availableModels: modelList,
1528
1698
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1529
1699
  availableExecutionModes: config.availableExecutionModes,
1530
1700
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1531
1701
  models: modelList,
1532
- defaults: config.sessionSettings,
1533
1702
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1534
1703
  workspaceRoots: workspaceRootMetadata,
1535
1704
  executionModes: config.availableExecutionModes,
@@ -1537,7 +1706,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1537
1706
  }),
1538
1707
  });
1539
1708
  writeState();
1540
- 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(', ')}`);
1541
1710
  }
1542
1711
  catch {
1543
1712
  writeState();
@@ -1582,57 +1751,41 @@ export async function main() {
1582
1751
  for (const warning of workspaceDiscovery.warnings) {
1583
1752
  console.error(`[canon-host] ${warning}`);
1584
1753
  }
1585
- const { agentName: profileAgentName, profile, sessionDefaults: rawSessionDefaults, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
1586
- const profileSessionDefaults = parseClaudeSessionDefaults(rawSessionDefaults);
1587
- activeLockHandle = lockHandle ?? null;
1754
+ const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
1588
1755
  console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
1589
- // ── Canon via the Bridge (bridge-execution-plan Phase 6) ──
1590
- // One connectBridge per host process: the daemon owns the SSE stream,
1591
- // RTDB auth, control polling, dedup, and reconnect. Notification handlers
1592
- // must exist BEFORE the hello response lands (replayed frames dispatch
1593
- // synchronously), so a buffered source subscribes inside `configure` and
1594
- // the real handlers attach after the session machinery is built.
1595
- const notifications = createBufferedNotificationSource([
1596
- 'ready',
1597
- 'connectionState',
1598
- 'onMessage',
1599
- 'onMessageDeleted',
1600
- 'onConversationUpdated',
1601
- 'onSessionControl',
1602
- 'onControlSignal',
1603
- 'onControlReply',
1604
- ]);
1605
- const bridge = await connectBridge({
1606
- ...(profile ? { profile } : {}),
1607
- mode: 'auto',
1608
- ...(resolvePackagedBridgeBin() ? { binPath: resolvePackagedBridgeBin() } : {}),
1609
- hello: {
1610
- clientType: 'claude-code',
1611
- wantFamilies: ['messages', 'runtime_turn'],
1612
- },
1613
- 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),
1614
1763
  });
1615
- // The BridgeClient's zod-derived result shapes mirror the core wire types
1616
- // structurally; the port pins the strong types the host machinery uses.
1617
- const port = bridge.client;
1618
- bridge.client.onProtocolError((error) => {
1619
- console.error(`[canon-host] Bridge protocol error: ${error.message}`);
1620
- });
1621
- const typingSignals = createPortTypingSignals(port);
1622
1764
  async function markQueuedMessageAcceptedForConversation(conversationId, sourceMessageId, markAccepted) {
1623
1765
  if (!markAccepted || !sourceMessageId)
1624
1766
  return;
1625
- await port.setDisposition({
1626
- conversationId,
1627
- messageId: sourceMessageId,
1628
- inboundDisposition: 'accepted_now',
1629
- }).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}`);
1630
1788
  }
1631
- const agentId = bridge.hello.agentId;
1632
- const ownerId = bridge.hello.agentContext.ownerId ?? null;
1633
- const ownerName = bridge.hello.agentContext.ownerName ?? null;
1634
- console.error(`[canon-host] Connected as ${bridge.hello.agentName || agentId} `
1635
- + `(canon-bridge${bridge.spawnedDaemon ? ' spawned' : ''} at ${bridge.socketPath})`);
1636
1789
  const launchArgs = [...process.argv.slice(2)];
1637
1790
  if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
1638
1791
  launchArgs.push('--cwd', workingDir);
@@ -1661,6 +1814,11 @@ export async function main() {
1661
1814
  lastStartedAt: new Date().toISOString(),
1662
1815
  lastHeartbeatAt: new Date().toISOString(),
1663
1816
  });
1817
+ const runtimeState = createRuntimeStatePublisher({
1818
+ agentId,
1819
+ clientType: 'claude-code',
1820
+ hostMode: true,
1821
+ });
1664
1822
  let streamConnected = false;
1665
1823
  const hostAvailableExecutionModes = [
1666
1824
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1673,60 +1831,23 @@ export async function main() {
1673
1831
  });
1674
1832
  let runtimeDescriptor = {
1675
1833
  defaultWorkspaceId: workspaceOptions[0]?.id,
1676
- defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1677
1834
  defaultPermissionMode: 'default',
1678
1835
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1679
1836
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1680
1837
  availableExecutionModes: hostAvailableExecutionModes,
1681
1838
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1682
1839
  models: runtimeModels,
1683
- defaults: profileSessionDefaults,
1684
1840
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1685
1841
  workspaceRoots: workspaceRootMetadata,
1686
1842
  executionModes: hostAvailableExecutionModes,
1687
1843
  presentation: runtimePresentation,
1688
1844
  }),
1689
1845
  };
1690
- // ── Conversation directory (shared inbound-ingestion bookkeeping from @canonmsg/agent-host) ──
1691
- const directory = createConversationDirectory({
1692
- agentId,
1693
- ownerId,
1694
- ownerName,
1695
- refreshFloorMs: HEARTBEAT_MS,
1696
- }, { port });
1697
- const { conversationCache, knownConversationIds, getConversationMeta, refreshKnownConversationIds, handleConversationUpdated, markGroupContextModeUsed, loadHydratedInboundContext, } = directory;
1698
- const publishSessionSnapshotsForConversations = async (conversationIds) => {
1699
- const ids = Array.from(new Set(conversationIds));
1700
- if (!streamConnected || ids.length === 0)
1701
- return;
1702
- await port.publishHostSessionSnapshots({
1703
- conversationIds: ids,
1704
- clientType: 'claude-code',
1705
- runtime: runtimeDescriptor,
1706
- workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
1707
- defaultCwd: workingDir,
1708
- extraSessionConfigFields: ['permissionMode', 'effort'],
1709
- liveSessionConfigByConversation: Object.fromEntries(Array.from(sessions.values()).map((session) => {
1710
- const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
1711
- return [
1712
- session.conversationId,
1713
- {
1714
- ...(session.state.model ? { model: session.state.model } : {}),
1715
- ...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
1716
- ...(session.state.effort ? { effort: session.state.effort } : {}),
1717
- ...(session.state.runtimeControlValues
1718
- ? { runtimeControlValues: session.state.runtimeControlValues }
1719
- : {}),
1720
- ...(workspaceId ? { workspaceId } : {}),
1721
- executionMode: session.environment.mode,
1722
- executionBranch: session.environment.branch ?? null,
1723
- },
1724
- ];
1725
- })),
1726
- }).catch((error) => {
1727
- console.error('[canon-host] Failed to publish session snapshots:', error);
1728
- });
1729
- };
1846
+ const conversationCache = new Map();
1847
+ const knownConversationIds = new Set();
1848
+ const promptedGroupContextConversationIds = new Set();
1849
+ const pendingMembershipChanges = new Map();
1850
+ let lastKnownConversationRefreshAt = 0;
1730
1851
  const publishRuntimeHeartbeat = async () => {
1731
1852
  heartbeatLocalRuntimeEntry(runtimeId, {
1732
1853
  agentId,
@@ -1747,20 +1868,42 @@ export async function main() {
1747
1868
  console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
1748
1869
  return runtimeMetadata;
1749
1870
  });
1750
- await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
1871
+ await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
1751
1872
  console.error('[canon-host] Failed to publish agent runtime:', error);
1752
1873
  });
1753
- 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
+ });
1754
1899
  // Rebuild so the descriptor picks up native slash commands from the
1755
1900
  // metadata refresh above — the initial descriptor is built before the
1756
1901
  // command inventory has loaded.
1757
1902
  if (runtimeDescriptor.runtimeDescriptor) {
1758
1903
  runtimeDescriptor = {
1759
1904
  ...runtimeDescriptor,
1760
- defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1761
1905
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1762
1906
  models: runtimeModels,
1763
- defaults: profileSessionDefaults,
1764
1907
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1765
1908
  workspaceRoots: workspaceRootMetadata,
1766
1909
  executionModes: hostAvailableExecutionModes,
@@ -1788,15 +1931,8 @@ export async function main() {
1788
1931
  executionBranch: session?.environment.branch ?? null,
1789
1932
  worktreePath: session?.environment.worktreePath ?? null,
1790
1933
  fallbackReason: resolveExecutionFallbackReason(session?.environment),
1791
- fallbackModel: session?.state.fallbackModel
1792
- ?? profileSessionDefaults.fallbackModel
1793
- ?? null,
1794
- ultracodeEnabled: (session?.state.runtimeControlValues?.ultracode ?? profileSessionDefaults.ultracode) === 'on',
1795
- });
1796
- await port.publishRuntimeStatus({
1797
- conversationId,
1798
- presentation: payload,
1799
1934
  });
1935
+ await runtimeState.writeRuntimeInfo(conversationId, payload);
1800
1936
  })).catch((error) => {
1801
1937
  console.error('[canon-host] Failed to publish runtime info:', error);
1802
1938
  });
@@ -1806,7 +1942,7 @@ export async function main() {
1806
1942
  runtimeModels = await detectRuntimeModels(workingDir);
1807
1943
  runtimeDescriptor = {
1808
1944
  defaultWorkspaceId: workspaceOptions[0]?.id,
1809
- defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1945
+ defaultModel: runtimeModels[0]?.value,
1810
1946
  defaultPermissionMode: 'default',
1811
1947
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1812
1948
  availableModels: runtimeModels,
@@ -1814,7 +1950,6 @@ export async function main() {
1814
1950
  availableExecutionModes: hostAvailableExecutionModes,
1815
1951
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1816
1952
  models: runtimeModels,
1817
- defaults: profileSessionDefaults,
1818
1953
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1819
1954
  workspaceRoots: workspaceRootMetadata,
1820
1955
  executionModes: hostAvailableExecutionModes,
@@ -1825,14 +1960,12 @@ export async function main() {
1825
1960
  catch {
1826
1961
  runtimeDescriptor = {
1827
1962
  defaultWorkspaceId: workspaceOptions[0]?.id,
1828
- defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1829
1963
  defaultPermissionMode: 'default',
1830
1964
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1831
1965
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1832
1966
  availableExecutionModes: hostAvailableExecutionModes,
1833
1967
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1834
1968
  models: runtimeModels,
1835
- defaults: profileSessionDefaults,
1836
1969
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1837
1970
  workspaceRoots: workspaceRootMetadata,
1838
1971
  executionModes: hostAvailableExecutionModes,
@@ -1841,12 +1974,14 @@ export async function main() {
1841
1974
  };
1842
1975
  }
1843
1976
  try {
1844
- const convos = await directory.listConversations();
1845
- directory.primeFromStartup(convos);
1977
+ const convos = await client.getConversations();
1978
+ lastKnownConversationRefreshAt = Date.now();
1846
1979
  for (const conversation of convos) {
1847
- port.clearStreaming({ conversationId: conversation.id }).catch(() => { });
1848
- port.clearSessionState({ conversationId: conversation.id }).catch(() => { });
1849
- 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(() => { });
1850
1985
  typingSignals.clear(conversation.id).catch(() => { });
1851
1986
  }
1852
1987
  console.error(`[canon-host] ${convos.length} conversations`);
@@ -1854,68 +1989,64 @@ export async function main() {
1854
1989
  catch {
1855
1990
  console.error('[canon-host] Could not load conversations');
1856
1991
  }
1857
- // ── Session manager (generic lifecycle from @canonmsg/agent-host) ──
1858
- const approvalManager = ownerId
1859
- ? new ApprovalManager(createPortApprovalClient(port), agentId, ownerId)
1860
- : null;
1861
- const lifecycle = createSessionLifecycle({
1862
- maxSessions: MAX_SESSIONS,
1863
- idleTimeoutMs: IDLE_TIMEOUT_MS,
1864
- heartbeatMs: HEARTBEAT_MS,
1865
- idleCheckMs: IDLE_CHECK_MS,
1866
- logPrefix: '[canon-host]',
1867
- }, {
1868
- knownConversationIds,
1869
- createSession: (conversationId) => createSessionForConversation(conversationId),
1870
- isSessionRunning: (session) => session.state.state === 'running',
1871
- writeTurnOnHeartbeat: (session) => session.state.state !== 'running' && session.state.state !== 'requires_action',
1872
- writeSessionState: (session) => session.writeState(),
1873
- writeSessionTurn: (session) => session.writeTurn(),
1874
- releaseEnvironment: (session) => releaseConversationEnvironment(session.environment),
1875
- persistResumeState,
1876
- disposeRuntime: (session) => session.query.close(),
1877
- interruptRuntime: (session) => session.query.interrupt(),
1878
- port,
1879
- publishRuntimeHeartbeat,
1880
- onShutdown: async () => {
1881
- detachControlNotifications();
1882
- notifications.detach();
1883
- approvalManager?.dispose();
1884
- await port.clearAgentRuntime().catch(() => { });
1885
- 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',
1886
2015
  },
1887
- onShutdownComplete: () => {
1888
- markLocalRuntimeStopped(runtimeId);
1889
- 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);
1890
2027
  },
1891
- });
1892
- const { sessions, getOrCreateSession, closeSession, resetRuntimeSession, markSessionPendingInputsRejected, removeQueuedInput, } = lifecycle;
1893
- // Interrupt/stop-clear signal handling (generic shell from @canonmsg/agent-host).
1894
- const handleRuntimeControlSignal = createRuntimeSignalHandler({
1895
- agentId,
1896
- logPrefix: '[canon-host]',
1897
- capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1898
- }, {
1899
- sessions,
1900
- port,
1901
- isSessionRunning: (session) => session.state.state === 'running',
1902
- interruptRuntime: (session) => session.query.interrupt(),
1903
- resetSession: (session) => resetRuntimeSession(session),
1904
- markSessionPendingInputsRejected,
1905
- });
1906
- // Control events arrive as bridge notifications — the bridge owns the ONE
1907
- // authoritative /control consumer for this identity (plan §2); the host's
1908
- // adaptive poller (active/idle cadence, jitter, per-session baselines) is
1909
- // deleted with it.
1910
- const detachControlNotifications = attachHostControlNotifications(notifications.source, { logPrefix: '[canon-host]' }, {
1911
- onSessionControl: applyRuntimeSessionControl,
1912
- onControlSignal: handleRuntimeControlSignal,
1913
2028
  });
1914
2029
  /** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
1915
2030
  const savedSessionIds = new Map();
2031
+ const { getConversationMeta } = createConversationMetadataLoader({
2032
+ client,
2033
+ conversationCache,
2034
+ });
1916
2035
  function resolveWorkspaceIdForBaseCwd(baseCwd) {
1917
2036
  return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
1918
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
+ }
1919
2050
  async function canRequestCanonApproval(conversationId) {
1920
2051
  if (!ownerId)
1921
2052
  return false;
@@ -1923,9 +2054,80 @@ export async function main() {
1923
2054
  .catch(() => conversationCache.get(conversationId) ?? null);
1924
2055
  return Array.isArray(conversation?.memberIds) && conversation.memberIds.includes(ownerId);
1925
2056
  }
1926
- /** Resume-state persistence on session close (claude-specific: SDK sessionId). */
1927
- function persistResumeState(session, saveResume) {
1928
- 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);
1929
2131
  const resumeKey = buildConversationEnvironmentKey(conversationId, session.environment.baseCwd);
1930
2132
  if (saveResume && session.sdkSessionId) {
1931
2133
  savedSessionIds.set(resumeKey, session.sdkSessionId);
@@ -1957,15 +2159,89 @@ export async function main() {
1957
2159
  });
1958
2160
  }
1959
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')}`);
1960
2181
  }
1961
- /** Runtime-specific session construction, invoked via the lifecycle's getOrCreateSession. */
1962
- function createSessionForConversation(conversationId) {
1963
- return (async () => {
1964
- const config = await loadSessionConfig(port, conversationId);
1965
- const sessionSettings = resolveEffectiveSessionSettings({
1966
- profileDefaults: profileSessionDefaults,
1967
- sessionConfig: config,
1968
- });
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);
1969
2245
  const sessionExecutionMode = resolveSessionExecutionMode(config);
1970
2246
  const workspaceCwd = resolveWorkspaceCwd(config);
1971
2247
  const environment = prepareConversationEnvironment({
@@ -1982,7 +2258,7 @@ export async function main() {
1982
2258
  executionMode: environment.mode,
1983
2259
  });
1984
2260
  const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
1985
- const session = createSession(conversationId, environment, agentId, port, typingSignals, (convoId, sdkSessionId) => {
2261
+ const session = createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, (convoId, sdkSessionId) => {
1986
2262
  // Called when session processing loop ends — save session ID for resume
1987
2263
  const existing = sessions.get(convoId);
1988
2264
  if (existing) {
@@ -2006,17 +2282,17 @@ export async function main() {
2006
2282
  }
2007
2283
  existing?.clearStreaming().catch(() => { });
2008
2284
  typingSignals.clear(convoId).catch(() => { });
2285
+ typingSignals.dispose(convoId);
2009
2286
  sessions.delete(convoId);
2010
- port.clearSessionState({ conversationId: convoId }).catch(() => { });
2011
- port.clearTurnState({ conversationId: convoId }).catch(() => { });
2287
+ runtimeState.clearSessionState(convoId).catch(() => { });
2288
+ runtimeState.clearTurnState(convoId).catch(() => { });
2012
2289
  }, (descriptor) => {
2013
2290
  runtimeDescriptor = descriptor;
2014
2291
  void publishRuntimeHeartbeat();
2015
2292
  }, {
2016
- model: sessionSettings.model ?? runtimeModels[0]?.value,
2293
+ model: config?.model ?? runtimeModels[0]?.value,
2017
2294
  permissionMode: config?.permissionMode ?? 'default',
2018
- effort: sessionSettings.effort,
2019
- sessionSettings,
2295
+ effort: config?.effort,
2020
2296
  availableModels: runtimeModels,
2021
2297
  availableExecutionModes: hostAvailableExecutionModes,
2022
2298
  runtimePresentation,
@@ -2026,6 +2302,7 @@ export async function main() {
2026
2302
  ownerId,
2027
2303
  }, resumeId);
2028
2304
  sessions.set(conversationId, session);
2305
+ await controlPoller.baseline([conversationId]);
2029
2306
  return session;
2030
2307
  }
2031
2308
  catch (error) {
@@ -2033,6 +2310,13 @@ export async function main() {
2033
2310
  throw error;
2034
2311
  }
2035
2312
  })();
2313
+ pendingSessionCreations.set(conversationId, creation);
2314
+ try {
2315
+ return await creation;
2316
+ }
2317
+ finally {
2318
+ pendingSessionCreations.delete(conversationId);
2319
+ }
2036
2320
  }
2037
2321
  async function enqueueRecoveredInboundMessage(input) {
2038
2322
  const m = input.message;
@@ -2089,9 +2373,7 @@ export async function main() {
2089
2373
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
2090
2374
  console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
2091
2375
  await markQueuedMessageAcceptedForConversation(input.conversationId, m.id ?? null, shouldMarkAccepted);
2092
- await port.sendMessage({
2093
- conversationId: input.conversationId,
2094
- 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}`, {
2095
2377
  messageId: `claude-start-failed-${m.id}`,
2096
2378
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2097
2379
  metadata: {
@@ -2134,261 +2416,332 @@ export async function main() {
2134
2416
  });
2135
2417
  }
2136
2418
  }
2137
- try {
2138
- for (const conversation of conversationCache.values()) {
2139
- const cursor = loadRuntimeSessionState(runtimeId, {
2140
- conversationId: conversation.id,
2141
- baseCwd: workingDir,
2142
- })?.lastInboundMessageId;
2143
- const recovery = await collectMissedInboundMessages({
2144
- fetchPage: (before) => port.getMessages({
2145
- conversationId: conversation.id,
2146
- limit: STARTUP_RECOVERY_PAGE_SIZE,
2147
- ...(before ? { before } : {}),
2148
- includeBehavior: true,
2149
- }),
2150
- cursor,
2151
- agentId,
2152
- });
2153
- if (recovery.mode === 'truncated-window') {
2154
- console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Startup recovery cursor not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; recovering truncated window`);
2155
- }
2156
- if (recovery.messages.length > 1) {
2157
- console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Recovered ${recovery.messages.length} missed messages in ${conversation.id}`);
2158
- }
2159
- for (const message of recovery.messages) {
2160
- console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Recovering inbound message on startup`);
2161
- await enqueueRecoveredInboundMessage({
2162
- conversationId: conversation.id,
2163
- message: message,
2164
- hydratedPage: recovery.newestPage,
2165
- });
2166
- }
2167
- }
2168
- }
2169
- catch (error) {
2170
- console.error('[canon-host] Failed startup recovery:', error instanceof Error ? error.message : error);
2171
- }
2172
- // ── Inbound Canon events (bridge notifications) ──
2173
- // The bridge owns SSE reconnect/heartbeat/replay + dedup + self-filtering;
2174
- // HITL control-reply messages (approval_reply / plan_approval_reply) are
2175
- // STRIPPED from onMessage bridge-side — unmatched ones arrive as
2176
- // onControlReply and feed the plan-approval waiters below.
2177
- const handleInboundMessage = (payload) => {
2178
- const m = payload.message;
2179
- if (m.senderId === agentId)
2180
- return;
2181
- const convoId = payload.conversationId;
2182
- knownConversationIds.add(convoId);
2183
- const sender = m.senderName || m.senderId;
2184
- const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2185
- if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2186
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2187
- return;
2188
- }
2189
- void (async () => {
2190
- let materialized = [];
2191
- if (m.id) {
2192
- try {
2193
- materialized = await materializeMessageMedia({
2194
- id: m.id,
2195
- attachments: m.attachments ?? [],
2196
- }, { 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
+ }
2197
2443
  }
2198
- catch (error) {
2199
- 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
+ }
2200
2470
  }
2201
- }
2202
- const content = renderInboundContent(m, materialized);
2203
- const hydrated = await loadHydratedInboundContext({
2204
- conversationId: convoId,
2205
- message: m,
2206
- senderName: sender,
2207
- isOwner,
2208
- activeSelfContextId: payload.activeSelfContextId,
2209
- selfContexts: payload.selfContexts,
2210
- provenance: payload.provenance,
2211
- });
2212
- const behavior = payload.behavior ?? hydrated.behavior;
2213
- const activeSelfContextId = hydrated.activeSelfContextId;
2214
- const selfContexts = hydrated.selfContexts;
2215
- const replyMedia = await materializePromptReplyContext({
2216
- replyContext: hydrated.replyContext,
2217
- agentId,
2218
- conversationId: convoId,
2219
- logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
2220
- });
2221
- const replyContext = replyMedia.replyContext;
2222
- const participantContext = hydrated.participantContext;
2223
- const autoReply = payload.turnDispatch?.kind === 'run_turn'
2224
- ? {
2225
- allow: true,
2226
- 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;
2227
2476
  }
2228
- : decideAutoReply(participantContext, behavior);
2229
- if (!autoReply.allow) {
2230
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
2231
- return;
2232
- }
2233
- markGroupContextModeUsed(convoId, participantContext.groupContextMode);
2234
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
2235
- const turnMetadata = normalizeTurnMetadata(m.metadata);
2236
- const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
2237
- const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
2238
- 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) {
2239
2613
  try {
2240
- 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}`);
2241
2618
  }
2242
- catch (error) {
2243
- const message = error instanceof Error ? error.message : String(error);
2244
- const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
2245
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
2246
- await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
2247
- await port.sendMessage({
2248
- conversationId: convoId,
2249
- text: `I couldn't start a coding session for this workspace: ${userMessage}`,
2250
- messageId: `claude-start-failed-${m.id}`,
2251
- ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2252
- metadata: {
2253
- turnSemantics: 'turn_complete',
2254
- replyBehavior: 'suppress_auto_reply',
2255
- },
2256
- }).catch(() => { });
2257
- return;
2258
- }
2259
- session.activeSelfContextId = activeSelfContextId;
2260
- const promptText = buildCanonPrompt({
2261
- content,
2262
- conversationId: convoId,
2263
- participantContext,
2264
- behavior,
2265
- selfContexts,
2266
- activeSelfContextId,
2267
- provenance: hydrated.provenance,
2268
- replyContext,
2269
- message: m,
2270
- });
2271
- const messageContent = await buildCanonUserContent({
2272
- promptText,
2273
- materialized: [...replyMedia.materialized, ...materialized],
2274
- });
2275
- const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
2276
- session.enqueueInbound({
2277
- type: 'user',
2278
- message: {
2279
- role: 'user',
2280
- content: messageContent,
2281
- },
2282
- parent_tool_use_id: null,
2283
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
2284
- if (m.id) {
2285
- saveRuntimeSessionState(runtimeId, {
2286
- conversationId: convoId,
2287
- baseCwd: workingDir,
2288
- lastInboundMessageId: m.id,
2289
- });
2290
- }
2291
- })();
2292
- };
2293
- /**
2294
- * Stripped HITL control replies that matched no bridge record: the plan
2295
- * approval flow (host-raised via a plain sendMessage) resolves its
2296
- * metadata-reply waiter here; anything else is fed to the approval
2297
- * manager as a late/second-chance consume.
2298
- */
2299
- const handleControlReply = (convoId, m) => {
2300
- if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
2301
- const consumed = approvalManager?.handleMessage(convoId, {
2302
- senderId: m.senderId,
2303
- metadata: m.metadata,
2304
- }) ?? false;
2305
- if (m.metadata?.type === 'approval_reply') {
2306
- console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
2307
- return;
2308
- }
2309
- if (consumed) {
2310
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
2311
- 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);
2312
2622
  }
2313
2623
  }
2314
- if (m.metadata?.type === 'plan_approval_reply') {
2315
- const existingSession = sessions.get(convoId);
2316
- const pendingReply = existingSession?.pendingReply;
2317
- const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2318
- const replyRequestId = m.metadata.planId;
2319
- if (!pendingReply) {
2320
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
2321
- return;
2322
- }
2323
- if (!isOwnerReply) {
2324
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
2325
- return;
2326
- }
2327
- if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
2328
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
2329
- 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}`);
2330
2635
  }
2331
- if (existingSession?.pendingReply) {
2332
- const { resolve } = existingSession.pendingReply;
2333
- existingSession.pendingReply = null;
2334
- existingSession.lastActivity = Date.now();
2335
- resolve(m.metadata);
2336
- 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);
2337
2639
  }
2338
2640
  }
2339
- };
2340
- notifications.attach({
2341
- onMessage: (params) => {
2342
- handleInboundMessage(params);
2343
- },
2344
- onMessageDeleted: (params) => {
2345
- const payload = params;
2346
- removeQueuedInput(payload.conversationId, payload.messageId);
2347
- },
2348
- onConversationUpdated: (params) => {
2349
- handleConversationUpdated(params);
2350
- },
2351
- onControlReply: (params) => {
2352
- const payload = params;
2353
- handleControlReply(payload.conversationId, payload.message);
2354
- },
2355
- ready: () => {
2356
- streamConnected = true;
2357
- void publishRuntimeHeartbeat();
2358
- console.error('[canon-host] Bridge ready');
2359
- },
2360
- connectionState: (params) => {
2361
- const { upstream } = params;
2362
- if (upstream === 'connected') {
2363
- streamConnected = true;
2364
- void publishRuntimeHeartbeat();
2365
- 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}`);
2366
2648
  }
2367
- else {
2368
- streamConnected = false;
2369
- port.clearAgentRuntime().catch(() => { });
2370
- 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);
2371
2652
  }
2372
- },
2373
- });
2374
- // ── Control signal watcher ──
2375
- // Session control signals (model, mode, effort). The bridge has already
2376
- // consumed the control notification, so every path must publish an answer.
2377
- 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) {
2378
2663
  const convoId = event.conversationId;
2379
2664
  const session = sessions.get(convoId);
2380
- if (!session || session.closed) {
2381
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Control received without live session; publishing persisted snapshot`);
2382
- 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);
2383
2670
  return;
2384
2671
  }
2385
- const ctrl = event.control;
2386
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
2387
- 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
+ }
2388
2701
  }
2389
- // ── Heartbeat + idle cleanup + graceful shutdown (agent-host lifecycle) ──
2390
- lifecycle.startTimers();
2391
- 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);
2392
2745
  console.error('[canon-host] Ready — sessions created on demand');
2393
2746
  // Keep alive
2394
2747
  await new Promise(() => { });
@@ -2397,7 +2750,7 @@ export async function main() {
2397
2750
  runCli(import.meta.url, main, (err) => {
2398
2751
  const message = err instanceof Error ? err.message : String(err);
2399
2752
  console.error(`[canon-host] ${message}`);
2400
- activeLockHandle?.release();
2753
+ getActiveProfileLock()?.release();
2401
2754
  process.exit(1);
2402
2755
  }, {
2403
2756
  name: 'canon-claude',