@canonmsg/claude-code-plugin 0.22.0 → 0.24.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 { applyClaudeSessionControl, 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: [],
@@ -228,6 +195,26 @@ function buildClaudeRuntimeDescriptor(input) {
228
195
  controlId: 'effort',
229
196
  },
230
197
  },
198
+ {
199
+ id: 'ultracode-details',
200
+ label: 'Ultracode',
201
+ description: 'Toggle Claude ultracode using /ultracode.',
202
+ aliases: ['ultracode'],
203
+ category: 'runtime',
204
+ placements: ['composer_slash', 'command_palette'],
205
+ availability: ['always'],
206
+ args: [{
207
+ id: 'level',
208
+ label: 'Level',
209
+ kind: 'enum',
210
+ required: true,
211
+ choices: [...CLAUDE_ULTRACODE_OPTIONS],
212
+ }],
213
+ dispatch: {
214
+ kind: 'control',
215
+ controlId: 'ultracode',
216
+ },
217
+ },
231
218
  {
232
219
  ...RUNTIME_NEW_SESSION_ACTION,
233
220
  primitive: 'session.new',
@@ -236,40 +223,24 @@ function buildClaudeRuntimeDescriptor(input) {
236
223
  RUNTIME_STOP_AND_DROP_ACTION,
237
224
  ];
238
225
  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
226
  return buildFirstPartyCodingRuntimeDescriptor({
262
227
  clientType: 'claude-code',
263
228
  models: input.models,
264
- defaultModel: input.defaults?.model ?? input.models[0]?.value ?? null,
265
229
  workspaces: input.workspaces,
266
230
  workspaceRoots: input.workspaceRoots,
267
231
  executionModes: input.executionModes,
268
232
  permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
269
233
  defaultPermissionMode: 'default',
270
234
  effortOptions: [...CLAUDE_EFFORT_OPTIONS],
271
- defaultEffort: input.defaults?.effort,
272
- extraRuntimeControls,
235
+ extraRuntimeControls: [{
236
+ id: 'ultracode',
237
+ label: 'Ultracode',
238
+ options: [...CLAUDE_ULTRACODE_OPTIONS],
239
+ defaultValue: 'off',
240
+ availability: 'setup_and_live',
241
+ liveBehavior: 'immediate',
242
+ selectionPolicy: 'inherit',
243
+ }],
273
244
  streamingTextMode: 'delta',
274
245
  turnModes: [
275
246
  {
@@ -295,6 +266,36 @@ function buildClaudeRuntimeDescriptor(input) {
295
266
  commands,
296
267
  });
297
268
  }
269
+ const CLAUDE_EFFORT_VALUES = new Set(CLAUDE_EFFORT_OPTIONS.map((option) => option.value));
270
+ function parseClaudePermissionMode(value) {
271
+ if (value === 'acceptEdits'
272
+ || value === 'auto'
273
+ || value === 'bypassPermissions'
274
+ || value === 'default'
275
+ || value === 'dontAsk'
276
+ || value === 'plan') {
277
+ return value;
278
+ }
279
+ return null;
280
+ }
281
+ function normalizeClaudeEffortLevel(value) {
282
+ return typeof value === 'string' && CLAUDE_EFFORT_VALUES.has(value)
283
+ ? value
284
+ : undefined;
285
+ }
286
+ async function applyClaudeEffortLevel(q, effort) {
287
+ await q.applyFlagSettings({ effortLevel: effort });
288
+ }
289
+ const CLAUDE_ULTRACODE_OPTIONS = [
290
+ { value: 'on', label: 'On' },
291
+ { value: 'off', label: 'Off' },
292
+ ];
293
+ function parseClaudeUltracodeMode(value) {
294
+ return value === 'on' || value === 'off' ? value : undefined;
295
+ }
296
+ async function applyClaudeUltracode(q, enabled) {
297
+ await q.applyFlagSettings({ ultracode: enabled });
298
+ }
298
299
  let cachedClaudeCliPath;
299
300
  // The SDK bundles a Bun-compiled `claude` Mach-O binary next to
300
301
  // claude-agent-sdk-darwin-arm64. On some macOS setups that exact binary gets
@@ -331,36 +332,28 @@ function resolveClaudeCliPath() {
331
332
  console.error('[canon-host] claude CLI not on PATH; using SDK-bundled binary');
332
333
  return undefined;
333
334
  }
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
335
  function toModelOptions(models) {
347
- return models.map(claudeModelInfoToOption);
336
+ return models.map((model) => ({
337
+ value: model.value,
338
+ label: model.displayName,
339
+ }));
348
340
  }
349
- async function publishAgentRuntime(port, runtime) {
350
- await port.publishHostAgentRuntime({
351
- clientType: 'claude-code',
352
- runtime: runtime,
353
- });
341
+ async function publishAgentRuntime(agentId, runtime) {
342
+ await publishHostAgentRuntime(agentId, 'claude-code', runtime);
354
343
  }
355
- async function loadSessionConfig(port, conversationId) {
356
- const raw = await port.getSessionConfig({ conversationId });
357
- const config = readHostSessionConfig(raw, ['permissionMode', 'effort']);
344
+ async function loadSessionConfig(conversationId, agentId) {
345
+ const config = await loadHostSessionConfig({
346
+ conversationId,
347
+ agentId,
348
+ extraStringFields: ['permissionMode', 'effort'],
349
+ });
358
350
  return {
359
351
  ...config,
360
352
  permissionMode: config?.permissionMode
361
353
  ? parseClaudePermissionMode(config.permissionMode) ?? undefined
362
354
  : undefined,
363
355
  effort: normalizeClaudeEffortLevel(config?.effort),
356
+ ultracode: parseClaudeUltracodeMode(config?.runtimeControlValues?.ultracode),
364
357
  };
365
358
  }
366
359
  function resolveSessionExecutionMode(config) {
@@ -384,21 +377,6 @@ function resolveExecutionFallbackReason(environment) {
384
377
  : environment.reason;
385
378
  }
386
379
  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
380
  const emptyPrompt = {
403
381
  async *[Symbol.asyncIterator]() { },
404
382
  };
@@ -408,7 +386,6 @@ async function detectRuntimeModelsForSelection(cwd, model) {
408
386
  options: {
409
387
  systemPrompt: { type: 'preset', preset: 'claude_code' },
410
388
  tools: { type: 'preset', preset: 'claude_code' },
411
- model,
412
389
  permissionMode: 'default',
413
390
  cwd,
414
391
  settings: { forceLoginMethod: 'claudeai' },
@@ -436,7 +413,6 @@ async function inspectClaudeRuntimeMetadata(cwd, activeQuery) {
436
413
  options: {
437
414
  systemPrompt: { type: 'preset', preset: 'claude_code' },
438
415
  tools: { type: 'preset', preset: 'claude_code' },
439
- model: 'default',
440
416
  permissionMode: 'default',
441
417
  cwd,
442
418
  settings: { forceLoginMethod: 'claudeai' },
@@ -486,6 +462,9 @@ async function refreshClaudeRuntimeMetadata(input) {
486
462
  });
487
463
  return runtimeMetadataRefresh;
488
464
  }
465
+ function sleep(ms) {
466
+ return new Promise((resolve) => setTimeout(resolve, ms));
467
+ }
489
468
  function mapClaudeQuestions(value) {
490
469
  if (!Array.isArray(value))
491
470
  return undefined;
@@ -617,22 +596,13 @@ function buildClaudeRuntimeInfo(input) {
617
596
  tier: 'diagnostic',
618
597
  }
619
598
  : null,
620
- input.fallbackModel
599
+ runtimeModels[0]
621
600
  ? {
622
601
  id: 'fallbackModel',
623
602
  label: 'Fallback model',
624
- value: input.fallbackModel,
603
+ value: runtimeModels[0].label,
625
604
  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',
605
+ source: 'host-default',
636
606
  }
637
607
  : null,
638
608
  {
@@ -725,6 +695,9 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
725
695
  const STREAMING_THROTTLE_MS = 250;
726
696
  const HEARTBEAT_MS = 30_000;
727
697
  const IDLE_CHECK_MS = 60_000;
698
+ const CONTROL_POLL_MS = 2000;
699
+ const IDLE_CONTROL_POLL_MS = 10_000;
700
+ const CONTROL_POLL_JITTER_MS = 1_000;
728
701
  const FINAL_DELIVERY_RETRY_MS = 30_000;
729
702
  const MAX_FINAL_DELIVERY_RETRIES = 1;
730
703
  const CLAUDE_RUNTIME_CAPABILITIES = {
@@ -793,7 +766,7 @@ async function buildCanonUserContent(input) {
793
766
  ];
794
767
  }
795
768
  // ── Session factory ─────────────────────────────────────────────────
796
- function createSession(conversationId, environment, agentId, port, typingSignals, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
769
+ function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
797
770
  const { cwd } = environment;
798
771
  const cardResponseUserId = config.ownerId ?? undefined;
799
772
  const cardResponseUser = cardResponseUserId ? { responseUserId: cardResponseUserId } : undefined;
@@ -853,15 +826,48 @@ function createSession(conversationId, environment, agentId, port, typingSignals
853
826
  // eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
854
827
  let session;
855
828
  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;
829
+ function waitForReply(type, requestId) {
830
+ // Keep session alive while waiting for user input
831
+ session.lastActivity = Date.now();
832
+ return new Promise((resolve, reject) => {
833
+ const timer = setTimeout(() => {
834
+ session.pendingReply = null;
835
+ reject(new Error('Timed out waiting for user reply'));
836
+ }, REPLY_TIMEOUT_MS);
837
+ session.pendingReply = {
838
+ type,
839
+ requestId,
840
+ resolve: (metadata) => {
841
+ clearTimeout(timer);
842
+ resolve(metadata);
843
+ },
844
+ };
845
+ });
846
+ }
847
+ async function waitForRuntimeInputResponse(input) {
848
+ session.lastActivity = Date.now();
849
+ while (Date.now() < input.expiresAt) {
850
+ const response = await client.consumeRuntimeInputResponse({
851
+ conversationId,
852
+ inputId: input.inputId,
853
+ }).catch(() => null);
854
+ if (response?.status === 'submitted') {
855
+ return { status: 'submitted', answers: response.answers, value: response.value };
856
+ }
857
+ if (response?.status === 'cancelled' || response?.status === 'timeout') {
858
+ return { status: response.status };
859
+ }
860
+ await sleep(1_000);
861
+ }
862
+ const response = await client.consumeRuntimeInputResponse({
863
+ conversationId,
864
+ inputId: input.inputId,
865
+ }).catch(() => null);
866
+ if (response?.status === 'submitted') {
867
+ return { status: 'submitted', answers: response.answers, value: response.value };
868
+ }
869
+ return { status: 'timeout' };
870
+ }
865
871
  function buildArtifactMediaMetadata() {
866
872
  return {
867
873
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
@@ -873,27 +879,16 @@ function createSession(conversationId, environment, agentId, port, typingSignals
873
879
  };
874
880
  }
875
881
  const claudeCliPath = resolveClaudeCliPath();
876
- const initialThinkingConfig = claudeThinkingToConfig(config.sessionSettings.thinking);
877
882
  const queryOptions = {
878
883
  systemPrompt: { type: 'preset', preset: 'claude_code' },
879
884
  tools: { type: 'preset', preset: 'claude_code' },
880
- model: config.model ?? 'default',
881
885
  permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
882
886
  allowDangerouslySkipPermissions: true,
883
887
  cwd,
884
888
  includePartialMessages: true,
885
889
  maxTurns: 200,
886
890
  ...(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
- },
891
+ settings: { forceLoginMethod: 'claudeai' },
897
892
  settingSources: ['project', 'local'],
898
893
  ...(claudeCliPath ? { pathToClaudeCodeExecutable: claudeCliPath } : {}),
899
894
  stderr: (data) => {
@@ -908,27 +903,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
908
903
  const questionId = randomUUID();
909
904
  const questions = mapClaudeQuestions(input.questions);
910
905
  const expiresAt = Date.now() + REPLY_TIMEOUT_MS;
911
- await port.requestInput({
906
+ await client.createRuntimeInputRequest({
912
907
  conversationId,
913
908
  inputId: questionId,
914
909
  kind: 'clarify',
915
- expiresAt: new Date(expiresAt).toISOString(),
910
+ expiresAt,
916
911
  title: 'Claude needs input',
917
912
  prompt: questions?.length
918
913
  ? 'Claude needs your input to continue.'
919
914
  : 'Claude needs input to continue.',
920
- ...(questions
921
- ? { questions: questions }
922
- : { questions: [] }),
915
+ ...(questions ? { questions } : {}),
923
916
  sensitive: Boolean(questions?.some((question) => question.isSecret)),
924
917
  ...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
925
918
  native: {
926
919
  runtime: 'claude-code',
927
920
  method: 'AskUserQuestion',
928
921
  requestId: questionId,
929
- ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
922
+ turnId: session.currentTurnId ?? undefined,
930
923
  },
931
- ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
924
+ turnId: session.currentTurnId ?? undefined,
932
925
  });
933
926
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
934
927
  const reply = await waitForRuntimeInputResponse({ inputId: questionId, expiresAt });
@@ -983,9 +976,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
983
976
  ...(cardResponseUser ?? {}),
984
977
  ...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
985
978
  });
986
- await port.sendMessage({
987
- conversationId,
988
- text: planApproval.text,
979
+ await sendMessageWithRetry(client, conversationId, planApproval.text, {
989
980
  messageId: `claude-plan-${planId}`,
990
981
  ...(session.activeSelfContextId
991
982
  ? { selfContextId: session.activeSelfContextId }
@@ -1037,6 +1028,9 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1037
1028
  if (session.state.permissionMode === 'plan') {
1038
1029
  session.state.permissionMode = 'default';
1039
1030
  writeState();
1031
+ // Converge the app immediately on the SDK-driven plan-mode exit
1032
+ // instead of waiting for the next 30s heartbeat (lag fix).
1033
+ config.publishSessionSnapshots?.([conversationId]);
1040
1034
  }
1041
1035
  return { behavior: 'allow' };
1042
1036
  }
@@ -1101,13 +1095,13 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1101
1095
  query: q,
1102
1096
  sendInput,
1103
1097
  enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactRoutingMode),
1104
- state: buildClaudeInitialSessionState({
1105
- model: config.model,
1098
+ state: {
1099
+ model: undefined,
1106
1100
  permissionMode: config.permissionMode,
1107
- effort: config.effort,
1108
- fallbackModel: config.sessionSettings.fallbackModel,
1109
- runtimeControlValues: config.sessionSettings.runtimeControlValues,
1110
- }),
1101
+ effort: undefined,
1102
+ ultracode: undefined,
1103
+ state: 'idle',
1104
+ },
1111
1105
  availableModels: config.availableModels,
1112
1106
  streamingText: '',
1113
1107
  streamingTimer: null,
@@ -1139,92 +1133,155 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1139
1133
  setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
1140
1134
  clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
1141
1135
  };
1142
- // ── Per-session RTDB writers (generic skeleton from @canonmsg/agent-host) ──
1143
- const writers = createRuntimeWriters({
1144
- conversationId,
1145
- agentId,
1146
- capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1147
- fallbackTurnId: `claude-${conversationId}`,
1148
- streamingThrottleMs: STREAMING_THROTTLE_MS,
1149
- requestedControls: {},
1150
- }, {
1151
- session: () => session,
1152
- port,
1153
- buildSessionStateExtras: (writerSession) => ({
1154
- cwd,
1155
- executionMode: writerSession.environment.mode,
1156
- ...(writerSession.environment.branch ? { executionBranch: writerSession.environment.branch } : {}),
1157
- ...(writerSession.environment.worktreePath ? { worktreePath: writerSession.environment.worktreePath } : {}),
1158
- ...(resolveExecutionFallbackReason(writerSession.environment)
1159
- ? { executionFallbackReason: resolveExecutionFallbackReason(writerSession.environment) ?? undefined }
1160
- : {}),
1161
- }),
1162
- formatControlError: formatClaudeControlError,
1136
+ const streamingOutput = createTurnOutputController({
1137
+ turnId: `claude-${conversationId}`,
1138
+ mode: 'delta',
1139
+ throttleMs: STREAMING_THROTTLE_MS,
1140
+ writeSnapshot: (snapshot) => {
1141
+ if (session.closed)
1142
+ return Promise.resolve();
1143
+ const turnId = session.currentTurnId ?? snapshot.turnId;
1144
+ session.streamingText = snapshot.text;
1145
+ const blocks = snapshot.blocks?.map((block) => ({ ...block, turnId }));
1146
+ return runtimeState.writeStreaming(conversationId, {
1147
+ text: snapshot.text,
1148
+ status: snapshot.status,
1149
+ messageId: turnId,
1150
+ turnId,
1151
+ blocks,
1152
+ });
1153
+ },
1154
+ clearSnapshot: () => {
1155
+ session.streamingText = '';
1156
+ return runtimeState.clearStreaming(conversationId);
1157
+ },
1163
1158
  });
1164
- const { streamingOutput, flushStreaming, onStreamDelta, clearStreaming, getFinalTurnTrail, writeState, writeTurn, setRuntimeControlError, clearRuntimeControlError, refreshVisibleWorkSignal, startVisibleWorkSignal, stopVisibleWorkSignal, } = writers;
1165
- // Initial values come from persisted session config and are effective for
1166
- // this query. Publish immediately so controls do not linger as requested.
1167
- writeState();
1159
+ // ── Per-session streaming helpers ──
1160
+ function flushStreaming() {
1161
+ streamingOutput.flush().catch(() => { });
1162
+ }
1163
+ function onStreamDelta(text, segmentId) {
1164
+ if (segmentId) {
1165
+ streamingOutput.appendTextSegmentDelta(segmentId, text);
1166
+ return;
1167
+ }
1168
+ streamingOutput.appendDelta(text);
1169
+ }
1168
1170
  function isActiveTurnInterrupted() {
1169
1171
  const turnKey = session.activeInput?.turnKey;
1170
1172
  return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
1171
1173
  }
1172
- // ── Turn queue + final delivery (generic skeleton from @canonmsg/agent-host) ──
1173
- const turnQueue = createTurnQueue({ conversationId }, {
1174
- session: () => session,
1175
- isSessionRunning: (queueSession) => queueSession.state.state === 'running',
1176
- markSessionRunning: (queueSession) => { queueSession.state.state = 'running'; },
1177
- prepareInput: (input) => prepareArtifactsForInput(input),
1178
- sendInput: (input) => sendInput(input),
1179
- interruptRuntime: (queueSession) => queueSession.query.interrupt(),
1180
- port,
1181
- writers: {
1182
- writeTurn,
1183
- stopVisibleWorkSignal,
1184
- clearStreaming,
1185
- },
1186
- });
1187
- const { startCanonInput, drainPendingInput, markQueuedMessageAccepted } = turnQueue;
1188
- const finalDelivery = createFinalDelivery({
1189
- conversationId,
1190
- logPrefix: '[canon-host]',
1191
- delivery: { mode: 'retry', retryMs: FINAL_DELIVERY_RETRY_MS, maxRetries: MAX_FINAL_DELIVERY_RETRIES },
1192
- }, {
1193
- session: () => session,
1194
- port,
1195
- typingSignals,
1196
- writers: {
1197
- writeState,
1198
- writeTurn,
1199
- stopVisibleWorkSignal,
1200
- clearStreaming,
1201
- getFinalTurnTrail,
1202
- replaceStreamingSnapshot: (text, status) => streamingOutput.replaceSnapshot(text, status),
1203
- },
1204
- resetCompletedTurnState: (deliverySession) => resetClaudeCompletedTurnState(deliverySession),
1205
- shouldDeliverFinal: (turn, deliverySession) => shouldDeliverClaudeFinal({
1174
+ function clearStreaming() {
1175
+ return streamingOutput.clear().catch(() => { });
1176
+ }
1177
+ function getFinalTurnTrail() {
1178
+ const turnId = session.currentTurnId ?? `claude-${conversationId}`;
1179
+ return streamingOutput.getFinalTrail().map((block) => ({ ...block, turnId }));
1180
+ }
1181
+ function clearIdleResetTimer() {
1182
+ if (!session.idleResetTimer)
1183
+ return;
1184
+ clearTimeout(session.idleResetTimer);
1185
+ session.idleResetTimer = null;
1186
+ }
1187
+ function clearFinalDeliveryTimer() {
1188
+ if (!session.finalDeliveryTimer)
1189
+ return;
1190
+ clearTimeout(session.finalDeliveryTimer);
1191
+ session.finalDeliveryTimer = null;
1192
+ }
1193
+ function resetTurnToIdle() {
1194
+ if (session.closed)
1195
+ return;
1196
+ clearFinalDeliveryTimer();
1197
+ resetClaudeCompletedTurnState(session);
1198
+ stopVisibleWorkSignal();
1199
+ writeState();
1200
+ writeTurn();
1201
+ drainPendingInput();
1202
+ }
1203
+ function scheduleFinalHandoffReset() {
1204
+ clearIdleResetTimer();
1205
+ session.idleResetTimer = setTimeout(() => {
1206
+ session.idleResetTimer = null;
1207
+ typingSignals.clear(conversationId).catch(() => { });
1208
+ clearStreaming();
1209
+ resetTurnToIdle();
1210
+ }, FINAL_MESSAGE_HANDOFF_MS);
1211
+ }
1212
+ function markPendingFinalDelivery(finalText, turn) {
1213
+ clearIdleResetTimer();
1214
+ const retryCount = session.pendingFinalDelivery?.turnKey === turn.turnKey
1215
+ ? session.pendingFinalDelivery.retryCount
1216
+ : 0;
1217
+ session.pendingFinalText = finalText;
1218
+ session.pendingFinalDelivery = {
1219
+ turnKey: turn.turnKey,
1220
+ text: finalText,
1221
+ retryCount,
1222
+ messageId: buildClaudeFinalMessageId({
1223
+ agentId,
1224
+ conversationId,
1225
+ turnKey: turn.turnKey,
1226
+ }),
1227
+ };
1228
+ session.state.state = 'running';
1229
+ session.turnState = 'streaming';
1230
+ stopVisibleWorkSignal();
1231
+ streamingOutput.replaceSnapshot(finalText, 'streaming').catch(() => { });
1232
+ writeState();
1233
+ writeTurn();
1234
+ typingSignals.start(conversationId, 'typing').catch(() => { });
1235
+ }
1236
+ async function deliverFinalReply(finalText, turn = session.activeInput) {
1237
+ if (!shouldDeliverClaudeFinal({
1206
1238
  turn,
1207
- finalizedTurnKeys: deliverySession.finalizedTurnKeys,
1208
- interruptedTurnKeys: deliverySession.interruptedTurnKeys,
1209
- }),
1210
- buildFinalMessageId: (turnKey) => buildClaudeFinalMessageId({ agentId, conversationId, turnKey }),
1211
- drainPendingInput: () => drainPendingInput(),
1212
- });
1213
- const { clearIdleResetTimer, clearFinalDeliveryTimer, resetTurnToIdle, scheduleFinalHandoffReset, markPendingFinalDelivery, deliverFinalReply, scheduleFinalDeliveryRetry, } = finalDelivery;
1214
- async function sendTurnArtifactFile(file) {
1215
- // Byte transfer is bridge-owned (§B8): hand the bridge a path, it streams
1216
- // the upload; then send the permanent message with the attachment ref.
1217
- const uploaded = await port.uploadMedia({
1218
- path: file.path,
1219
- mime: inferUploadMimeType(file.path),
1220
- fileName: file.fileName,
1239
+ finalizedTurnKeys: session.finalizedTurnKeys,
1240
+ interruptedTurnKeys: session.interruptedTurnKeys,
1241
+ })) {
1242
+ return true;
1243
+ }
1244
+ const deliverTurn = turn;
1245
+ if (!deliverTurn)
1246
+ return true;
1247
+ const finalMessageId = buildClaudeFinalMessageId({
1248
+ agentId,
1221
1249
  conversationId,
1250
+ turnKey: deliverTurn.turnKey,
1222
1251
  });
1223
- return port.sendMessage({
1224
- conversationId,
1225
- text: '',
1226
- contentType: uploaded.attachment.kind,
1227
- attachments: [uploaded.attachment],
1252
+ try {
1253
+ await sendMessageWithRetry(client, conversationId, finalText, {
1254
+ messageId: finalMessageId,
1255
+ ...(session.activeSelfContextId
1256
+ ? { selfContextId: session.activeSelfContextId }
1257
+ : {}),
1258
+ metadata: {
1259
+ turnId: session.currentTurnId,
1260
+ turnKey: deliverTurn.turnKey,
1261
+ sourceMessageId: deliverTurn.sourceMessageId ?? undefined,
1262
+ turnSemantics: 'turn_complete',
1263
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
1264
+ ...(getFinalTurnTrail().length > 0
1265
+ ? { turnTrail: getFinalTurnTrail() }
1266
+ : {}),
1267
+ },
1268
+ });
1269
+ session.finalizedTurnKeys.add(deliverTurn.turnKey);
1270
+ session.state.state = 'idle';
1271
+ session.pendingFinalDelivery = null;
1272
+ clearFinalDeliveryTimer();
1273
+ writeState();
1274
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars)`);
1275
+ return true;
1276
+ }
1277
+ catch (err) {
1278
+ markPendingFinalDelivery(finalText, deliverTurn);
1279
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
1280
+ return false;
1281
+ }
1282
+ }
1283
+ function sendTurnArtifactFile(file) {
1284
+ return sendMediaFileMessage(client, conversationId, file.path, '', {
1228
1285
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1229
1286
  metadata: buildArtifactMediaMetadata(),
1230
1287
  });
@@ -1261,6 +1318,145 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1261
1318
  await routeWorkspaceGeneratedArtifacts(input);
1262
1319
  }
1263
1320
  }
1321
+ function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
1322
+ if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
1323
+ return;
1324
+ if (pending.retryCount >= MAX_FINAL_DELIVERY_RETRIES) {
1325
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply delivery exhausted after bounded retries`);
1326
+ session.pendingFinalDelivery = null;
1327
+ resetTurnToIdle();
1328
+ return;
1329
+ }
1330
+ pending.retryCount += 1;
1331
+ session.pendingFinalDelivery = pending;
1332
+ clearFinalDeliveryTimer();
1333
+ session.finalDeliveryTimer = setTimeout(() => {
1334
+ session.finalDeliveryTimer = null;
1335
+ const retryTurn = session.activeInput?.turnKey === pending.turnKey
1336
+ ? session.activeInput
1337
+ : null;
1338
+ void deliverFinalReply(pending.text, retryTurn).then((sent) => {
1339
+ if (sent) {
1340
+ scheduleFinalHandoffReset();
1341
+ return;
1342
+ }
1343
+ scheduleFinalDeliveryRetry();
1344
+ });
1345
+ }, FINAL_DELIVERY_RETRY_MS);
1346
+ }
1347
+ function writeState() {
1348
+ const runtimeControlErrors = Object.keys(session.runtimeControlErrors).length > 0
1349
+ ? session.runtimeControlErrors
1350
+ : null;
1351
+ const controlState = {};
1352
+ const appliedAt = Date.now();
1353
+ if (session.state.model !== undefined) {
1354
+ controlState.model = { value: session.state.model, source: 'applied', appliedAt };
1355
+ }
1356
+ else if (config.model !== undefined) {
1357
+ controlState.model = { value: config.model, source: 'requested' };
1358
+ }
1359
+ if (session.state.permissionMode !== undefined) {
1360
+ controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
1361
+ }
1362
+ else if (config.permissionMode !== undefined) {
1363
+ controlState.permissionMode = { value: config.permissionMode, source: 'requested' };
1364
+ }
1365
+ if (session.state.effort !== undefined) {
1366
+ controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
1367
+ }
1368
+ else if (config.effort !== undefined) {
1369
+ controlState.effort = { value: config.effort, source: 'requested' };
1370
+ }
1371
+ if (session.state.ultracode !== undefined) {
1372
+ controlState.ultracode = { value: session.state.ultracode, source: 'applied', appliedAt };
1373
+ }
1374
+ else if (config.ultracode !== undefined) {
1375
+ controlState.ultracode = { value: config.ultracode, source: 'requested' };
1376
+ }
1377
+ runtimeState.writeSessionState(conversationId, {
1378
+ model: session.state.model,
1379
+ permissionMode: session.state.permissionMode,
1380
+ effort: session.state.effort,
1381
+ ...(session.state.ultracode ? { runtimeControlValues: { ultracode: session.state.ultracode } } : {}),
1382
+ controlState,
1383
+ runtimeControlErrors,
1384
+ contextUsage: session.state.contextUsage,
1385
+ ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
1386
+ cwd,
1387
+ executionMode: session.environment.mode,
1388
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
1389
+ ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
1390
+ ...(resolveExecutionFallbackReason(session.environment)
1391
+ ? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
1392
+ : {}),
1393
+ hostMode: true,
1394
+ isActive: true,
1395
+ }).catch(() => { });
1396
+ }
1397
+ function setRuntimeControlError(controlId, value, error) {
1398
+ session.runtimeControlErrors = {
1399
+ ...session.runtimeControlErrors,
1400
+ [controlId]: {
1401
+ value,
1402
+ message: formatClaudeControlError(error),
1403
+ updatedAt: Date.now(),
1404
+ },
1405
+ };
1406
+ writeState();
1407
+ }
1408
+ function clearRuntimeControlError(controlId) {
1409
+ if (!(controlId in session.runtimeControlErrors))
1410
+ return;
1411
+ const next = { ...session.runtimeControlErrors };
1412
+ delete next[controlId];
1413
+ session.runtimeControlErrors = next;
1414
+ writeState();
1415
+ }
1416
+ function writeTurn() {
1417
+ const isOpenTurn = session.turnState === 'thinking'
1418
+ || session.turnState === 'streaming'
1419
+ || session.turnState === 'tool'
1420
+ || session.turnState === 'waiting_input';
1421
+ if (isOpenTurn) {
1422
+ session.currentTurnUpdatedAt = Date.now();
1423
+ }
1424
+ runtimeState.writeTurnState(conversationId, {
1425
+ turnId: session.currentTurnId,
1426
+ state: session.turnState,
1427
+ queueDepth: session.pendingInputs.length,
1428
+ currentSpeakerId: agentId,
1429
+ lastAcceptedIntent: session.lastAcceptedIntent,
1430
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1431
+ ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
1432
+ ...(isOpenTurn && session.currentTurnUpdatedAt ? { turnUpdatedAt: session.currentTurnUpdatedAt } : {}),
1433
+ ...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
1434
+ ? { completedAt: { '.sv': 'timestamp' } }
1435
+ : {}),
1436
+ }).catch(() => { });
1437
+ }
1438
+ function refreshVisibleWorkSignal() {
1439
+ if (session.closed)
1440
+ return;
1441
+ if (session.turnState !== 'thinking' && session.turnState !== 'tool')
1442
+ return;
1443
+ typingSignals.start(conversationId, 'thinking').catch(() => { });
1444
+ }
1445
+ function startVisibleWorkSignal() {
1446
+ refreshVisibleWorkSignal();
1447
+ }
1448
+ function stopVisibleWorkSignal() {
1449
+ if (session.typingKeepaliveTimer) {
1450
+ clearInterval(session.typingKeepaliveTimer);
1451
+ session.typingKeepaliveTimer = null;
1452
+ }
1453
+ typingSignals.clear(conversationId).catch(() => { });
1454
+ }
1455
+ async function markQueuedMessageAccepted(sourceMessageId, markAccepted) {
1456
+ if (!markAccepted || !sourceMessageId)
1457
+ return;
1458
+ await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
1459
+ }
1264
1460
  async function prepareArtifactsForInput(input) {
1265
1461
  if (input.kind !== 'canon')
1266
1462
  return;
@@ -1276,8 +1472,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1276
1472
  return;
1277
1473
  }
1278
1474
  }
1475
+ async function startCanonInput(input) {
1476
+ await markQueuedMessageAccepted(input.sourceMessageId, input.markAccepted);
1477
+ await prepareArtifactsForInput(input);
1478
+ session.state.state = 'running';
1479
+ sendInput(input);
1480
+ }
1481
+ function drainPendingInput() {
1482
+ if (session.closed || session.state.state === 'running' || session.pendingInputs.length === 0)
1483
+ return;
1484
+ const next = session.pendingInputs.shift();
1485
+ if (!next)
1486
+ return;
1487
+ session.lastAcceptedIntent = next.intent;
1488
+ writeTurn();
1489
+ void startCanonInput(next);
1490
+ }
1279
1491
  enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
1280
- turnQueue.enqueueCanonInput(createClaudeInputEnvelope({
1492
+ session.lastActivity = Date.now();
1493
+ const input = createClaudeInputEnvelope({
1281
1494
  kind: 'canon',
1282
1495
  msg,
1283
1496
  intent,
@@ -1285,11 +1498,35 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1285
1498
  markAccepted,
1286
1499
  isOwnerTurn,
1287
1500
  artifactRoutingMode,
1288
- }));
1501
+ });
1502
+ if (session.pendingFinalDelivery) {
1503
+ session.pendingInputs.push(input);
1504
+ writeTurn();
1505
+ return;
1506
+ }
1507
+ if (session.state.state === 'running' && intent !== 'interrupt') {
1508
+ session.pendingInputs.push(input);
1509
+ writeTurn();
1510
+ return;
1511
+ }
1512
+ if (session.state.state === 'running' && intent === 'interrupt') {
1513
+ if (session.activeInput) {
1514
+ session.interruptedTurnKeys.add(session.activeInput.turnKey);
1515
+ }
1516
+ session.pendingInputs.unshift(input);
1517
+ session.turnState = 'interrupted';
1518
+ stopVisibleWorkSignal();
1519
+ clearStreaming().catch(() => { });
1520
+ writeTurn();
1521
+ void session.query.interrupt().catch(() => { });
1522
+ return;
1523
+ }
1524
+ session.lastAcceptedIntent = intent;
1525
+ writeTurn();
1526
+ void startCanonInput(input);
1289
1527
  };
1290
1528
  writeTurn();
1291
1529
  // ── Process SDK output messages ──
1292
- let assistantStreamOrdinal = 0;
1293
1530
  const processMessages = async () => {
1294
1531
  try {
1295
1532
  for await (const msg of q) {
@@ -1320,10 +1557,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1320
1557
  stopVisibleWorkSignal();
1321
1558
  break;
1322
1559
  }
1323
- if (event?.type === 'message_start') {
1324
- assistantStreamOrdinal += 1;
1325
- }
1326
- else if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
1560
+ if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
1327
1561
  session.toolInProgress = true;
1328
1562
  session.turnState = 'tool';
1329
1563
  streamingOutput.addBlock({
@@ -1352,35 +1586,12 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1352
1586
  stopVisibleWorkSignal();
1353
1587
  writeTurn();
1354
1588
  const blockIndex = typeof event.index === 'number' ? event.index : 'latest';
1355
- const messageOrdinal = assistantStreamOrdinal || 1;
1356
- onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${messageOrdinal}:${blockIndex}`);
1589
+ onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${blockIndex}`);
1357
1590
  }
1358
1591
  break;
1359
1592
  }
1360
1593
  case 'system': {
1361
1594
  const subtype = msg.subtype;
1362
- if (subtype === 'task_started'
1363
- || subtype === 'task_progress'
1364
- || subtype === 'task_updated'
1365
- || subtype === 'task_notification') {
1366
- const activity = mapClaudeSubagentActivity(msg);
1367
- if (activity && !isActiveTurnInterrupted()) {
1368
- if (!session.currentTurnId) {
1369
- session.currentTurnId = randomUUID();
1370
- session.currentTurnOpenedAt = Date.now();
1371
- }
1372
- if (session.state.state === 'running' && session.turnState !== 'streaming') {
1373
- session.turnState = 'thinking';
1374
- startVisibleWorkSignal();
1375
- writeTurn();
1376
- }
1377
- await streamingOutput.addBlock(activity).catch(() => { });
1378
- }
1379
- }
1380
- if (subtype === 'model_refusal_fallback') {
1381
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Model refusal fallback: `
1382
- + `${msg.original_model} → ${msg.fallback_model}`);
1383
- }
1384
1595
  if (subtype === 'session_state_changed') {
1385
1596
  const state = msg.state;
1386
1597
  session.state.state = state;
@@ -1392,7 +1603,6 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1392
1603
  }
1393
1604
  session.state.contextUsage = undefined;
1394
1605
  session.pendingFinalText = null;
1395
- assistantStreamOrdinal = 0;
1396
1606
  session.toolInProgress = false;
1397
1607
  session.turnState = 'thinking';
1398
1608
  startVisibleWorkSignal();
@@ -1489,6 +1699,33 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1489
1699
  const init = async () => {
1490
1700
  processMessages().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] processMessages crashed:`, err));
1491
1701
  await q.initializationResult();
1702
+ if (config.model) {
1703
+ try {
1704
+ await q.setModel(config.model);
1705
+ session.state.model = config.model;
1706
+ }
1707
+ catch (err) {
1708
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial model:`, err);
1709
+ }
1710
+ }
1711
+ if (config.effort) {
1712
+ try {
1713
+ await applyClaudeEffortLevel(q, config.effort);
1714
+ session.state.effort = config.effort;
1715
+ }
1716
+ catch (err) {
1717
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial effort:`, err);
1718
+ }
1719
+ }
1720
+ if (config.ultracode) {
1721
+ try {
1722
+ await applyClaudeUltracode(q, config.ultracode === 'on');
1723
+ session.state.ultracode = config.ultracode;
1724
+ }
1725
+ catch (err) {
1726
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial ultracode:`, err);
1727
+ }
1728
+ }
1492
1729
  if (!resumeSessionId) {
1493
1730
  // New session — send seed message to activate streaming input mode
1494
1731
  sendInput(createClaudeInputEnvelope({
@@ -1503,32 +1740,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1503
1740
  },
1504
1741
  }));
1505
1742
  }
1506
- // Refresh the canonical runtime descriptor from a neutral probe. The live
1507
- // session may be initialized with a selected model, which can narrow the
1508
- // SDK's cached model list.
1743
+ // Refresh the canonical runtime descriptor after Claude reports supported models.
1509
1744
  try {
1510
- const discoveredModels = await detectRuntimeModels(cwd).catch((error) => {
1511
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Model discovery failed:`, error);
1512
- return runtimeModels;
1513
- });
1514
- if (discoveredModels.length > 0) {
1515
- runtimeModels = discoveredModels;
1516
- }
1517
- const modelList = resolveClaudeModelOptions({
1518
- discovered: runtimeModels,
1745
+ const models = await q.supportedModels();
1746
+ const modelList = mergeClaudeModelOptions({
1747
+ previous: runtimeModels,
1748
+ next: toModelOptions(models),
1519
1749
  selectedModel: session.state.model,
1520
1750
  });
1521
1751
  session.availableModels = modelList;
1752
+ runtimeModels = modelList;
1522
1753
  onRuntimeDescriptorUpdate({
1523
1754
  defaultWorkspaceId: workspaceOptions[0]?.id,
1524
- defaultModel: config.sessionSettings.model ?? modelList[0]?.value,
1755
+ defaultModel: modelList[0]?.value,
1525
1756
  defaultPermissionMode: 'default',
1526
1757
  availableModels: modelList,
1527
1758
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1528
1759
  availableExecutionModes: config.availableExecutionModes,
1529
1760
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1530
1761
  models: modelList,
1531
- defaults: config.sessionSettings,
1532
1762
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1533
1763
  workspaceRoots: workspaceRootMetadata,
1534
1764
  executionModes: config.availableExecutionModes,
@@ -1536,7 +1766,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1536
1766
  }),
1537
1767
  });
1538
1768
  writeState();
1539
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${modelList.map((m) => m.label).join(', ')}`);
1769
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${models.map((m) => m.displayName).join(', ')}`);
1540
1770
  }
1541
1771
  catch {
1542
1772
  writeState();
@@ -1581,57 +1811,41 @@ export async function main() {
1581
1811
  for (const warning of workspaceDiscovery.warnings) {
1582
1812
  console.error(`[canon-host] ${warning}`);
1583
1813
  }
1584
- const { agentName: profileAgentName, profile, sessionDefaults: rawSessionDefaults, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
1585
- const profileSessionDefaults = parseClaudeSessionDefaults(rawSessionDefaults);
1586
- activeLockHandle = lockHandle ?? null;
1814
+ const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
1587
1815
  console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
1588
- // ── Canon via the Bridge (bridge-execution-plan Phase 6) ──
1589
- // One connectBridge per host process: the daemon owns the SSE stream,
1590
- // RTDB auth, control polling, dedup, and reconnect. Notification handlers
1591
- // must exist BEFORE the hello response lands (replayed frames dispatch
1592
- // synchronously), so a buffered source subscribes inside `configure` and
1593
- // the real handlers attach after the session machinery is built.
1594
- const notifications = createBufferedNotificationSource([
1595
- 'ready',
1596
- 'connectionState',
1597
- 'onMessage',
1598
- 'onMessageDeleted',
1599
- 'onConversationUpdated',
1600
- 'onSessionControl',
1601
- 'onControlSignal',
1602
- 'onControlReply',
1603
- ]);
1604
- const bridge = await connectBridge({
1605
- ...(profile ? { profile } : {}),
1606
- mode: 'auto',
1607
- ...(resolvePackagedBridgeBin() ? { binPath: resolvePackagedBridgeBin() } : {}),
1608
- hello: {
1609
- clientType: 'claude-code',
1610
- wantFamilies: ['messages', 'runtime_turn'],
1611
- },
1612
- configure: (bridgeClient) => notifications.connect(bridgeClient),
1613
- });
1614
- // The BridgeClient's zod-derived result shapes mirror the core wire types
1615
- // structurally; the port pins the strong types the host machinery uses.
1616
- const port = bridge.client;
1617
- bridge.client.onProtocolError((error) => {
1618
- console.error(`[canon-host] Bridge protocol error: ${error.message}`);
1816
+ // ── Canon API client + RTDB auth ──
1817
+ const client = new CanonClient(apiKey, baseUrl);
1818
+ const rtdb = initRTDBAuth(client);
1819
+ const typingSignals = createTypingStatusPublisher({
1820
+ setTyping: (conversationId, typing, status) => status
1821
+ ? client.setTyping(conversationId, typing, status)
1822
+ : client.setTyping(conversationId, typing),
1619
1823
  });
1620
- const typingSignals = createPortTypingSignals(port);
1621
1824
  async function markQueuedMessageAcceptedForConversation(conversationId, sourceMessageId, markAccepted) {
1622
1825
  if (!markAccepted || !sourceMessageId)
1623
1826
  return;
1624
- await port.setDisposition({
1625
- conversationId,
1626
- messageId: sourceMessageId,
1627
- inboundDisposition: 'accepted_now',
1628
- }).catch(() => { });
1827
+ await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
1828
+ }
1829
+ let agentId;
1830
+ let ownerId = null;
1831
+ let ownerName = null;
1832
+ try {
1833
+ const ctx = await client.getAgentMe();
1834
+ agentId = ctx.agentId;
1835
+ ownerId = ctx.ownerId;
1836
+ ownerName = ctx.ownerName;
1837
+ console.error(`[canon-host] Connected as ${ctx.displayName || agentId}`);
1838
+ }
1839
+ catch {
1840
+ if (profileAgentId) {
1841
+ agentId = profileAgentId;
1842
+ }
1843
+ else {
1844
+ const auth = await client.getAuthToken();
1845
+ agentId = auth.agentId;
1846
+ }
1847
+ console.error(`[canon-host] Authenticated as ${agentId}`);
1629
1848
  }
1630
- const agentId = bridge.hello.agentId;
1631
- const ownerId = bridge.hello.agentContext.ownerId ?? null;
1632
- const ownerName = bridge.hello.agentContext.ownerName ?? null;
1633
- console.error(`[canon-host] Connected as ${bridge.hello.agentName || agentId} `
1634
- + `(canon-bridge${bridge.spawnedDaemon ? ' spawned' : ''} at ${bridge.socketPath})`);
1635
1849
  const launchArgs = [...process.argv.slice(2)];
1636
1850
  if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
1637
1851
  launchArgs.push('--cwd', workingDir);
@@ -1660,6 +1874,11 @@ export async function main() {
1660
1874
  lastStartedAt: new Date().toISOString(),
1661
1875
  lastHeartbeatAt: new Date().toISOString(),
1662
1876
  });
1877
+ const runtimeState = createRuntimeStatePublisher({
1878
+ agentId,
1879
+ clientType: 'claude-code',
1880
+ hostMode: true,
1881
+ });
1663
1882
  let streamConnected = false;
1664
1883
  const hostAvailableExecutionModes = [
1665
1884
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1672,40 +1891,39 @@ export async function main() {
1672
1891
  });
1673
1892
  let runtimeDescriptor = {
1674
1893
  defaultWorkspaceId: workspaceOptions[0]?.id,
1675
- defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1676
1894
  defaultPermissionMode: 'default',
1677
1895
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1678
1896
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1679
1897
  availableExecutionModes: hostAvailableExecutionModes,
1680
1898
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1681
1899
  models: runtimeModels,
1682
- defaults: profileSessionDefaults,
1683
1900
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1684
1901
  workspaceRoots: workspaceRootMetadata,
1685
1902
  executionModes: hostAvailableExecutionModes,
1686
1903
  presentation: runtimePresentation,
1687
1904
  }),
1688
1905
  };
1689
- // ── Conversation directory (shared inbound-ingestion bookkeeping from @canonmsg/agent-host) ──
1690
- const directory = createConversationDirectory({
1691
- agentId,
1692
- ownerId,
1693
- ownerName,
1694
- refreshFloorMs: HEARTBEAT_MS,
1695
- }, { port });
1696
- const { conversationCache, knownConversationIds, getConversationMeta, refreshKnownConversationIds, handleConversationUpdated, markGroupContextModeUsed, loadHydratedInboundContext, } = directory;
1697
- const publishSessionSnapshotsForConversations = async (conversationIds) => {
1698
- const ids = Array.from(new Set(conversationIds));
1699
- if (!streamConnected || ids.length === 0)
1906
+ const conversationCache = new Map();
1907
+ const knownConversationIds = new Set();
1908
+ const promptedGroupContextConversationIds = new Set();
1909
+ const pendingMembershipChanges = new Map();
1910
+ let lastKnownConversationRefreshAt = 0;
1911
+ // Publish the app-subscribed /agent-session snapshot for the given
1912
+ // conversations. Extracted from the heartbeat so control handlers can converge
1913
+ // the app immediately instead of waiting for the next 30s heartbeat
1914
+ // (session-control lag fix). Hoisted so it is reachable from earlier code.
1915
+ async function publishSessionSnapshots(conversationIds) {
1916
+ if (!streamConnected || conversationIds.length === 0)
1700
1917
  return;
1701
- await port.publishHostSessionSnapshots({
1702
- conversationIds: ids,
1918
+ await publishHostSessionSnapshots({
1919
+ conversationIds,
1920
+ agentId,
1703
1921
  clientType: 'claude-code',
1704
1922
  runtime: runtimeDescriptor,
1705
- workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
1923
+ workspaceOptions,
1706
1924
  defaultCwd: workingDir,
1707
1925
  extraSessionConfigFields: ['permissionMode', 'effort'],
1708
- liveSessionConfigByConversation: Object.fromEntries(Array.from(sessions.values()).map((session) => {
1926
+ liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
1709
1927
  const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
1710
1928
  return [
1711
1929
  session.conversationId,
@@ -1713,9 +1931,7 @@ export async function main() {
1713
1931
  ...(session.state.model ? { model: session.state.model } : {}),
1714
1932
  ...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
1715
1933
  ...(session.state.effort ? { effort: session.state.effort } : {}),
1716
- ...(session.state.runtimeControlValues
1717
- ? { runtimeControlValues: session.state.runtimeControlValues }
1718
- : {}),
1934
+ ...(session.state.ultracode ? { runtimeControlValues: { ultracode: session.state.ultracode } } : {}),
1719
1935
  ...(workspaceId ? { workspaceId } : {}),
1720
1936
  executionMode: session.environment.mode,
1721
1937
  executionBranch: session.environment.branch ?? null,
@@ -1725,7 +1941,7 @@ export async function main() {
1725
1941
  }).catch((error) => {
1726
1942
  console.error('[canon-host] Failed to publish session snapshots:', error);
1727
1943
  });
1728
- };
1944
+ }
1729
1945
  const publishRuntimeHeartbeat = async () => {
1730
1946
  heartbeatLocalRuntimeEntry(runtimeId, {
1731
1947
  agentId,
@@ -1746,20 +1962,18 @@ export async function main() {
1746
1962
  console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
1747
1963
  return runtimeMetadata;
1748
1964
  });
1749
- await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
1965
+ await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
1750
1966
  console.error('[canon-host] Failed to publish agent runtime:', error);
1751
1967
  });
1752
- await publishSessionSnapshotsForConversations(knownConversationIds);
1968
+ await publishSessionSnapshots(Array.from(knownConversationIds));
1753
1969
  // Rebuild so the descriptor picks up native slash commands from the
1754
1970
  // metadata refresh above — the initial descriptor is built before the
1755
1971
  // command inventory has loaded.
1756
1972
  if (runtimeDescriptor.runtimeDescriptor) {
1757
1973
  runtimeDescriptor = {
1758
1974
  ...runtimeDescriptor,
1759
- defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1760
1975
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1761
1976
  models: runtimeModels,
1762
- defaults: profileSessionDefaults,
1763
1977
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1764
1978
  workspaceRoots: workspaceRootMetadata,
1765
1979
  executionModes: hostAvailableExecutionModes,
@@ -1787,15 +2001,8 @@ export async function main() {
1787
2001
  executionBranch: session?.environment.branch ?? null,
1788
2002
  worktreePath: session?.environment.worktreePath ?? null,
1789
2003
  fallbackReason: resolveExecutionFallbackReason(session?.environment),
1790
- fallbackModel: session?.state.fallbackModel
1791
- ?? profileSessionDefaults.fallbackModel
1792
- ?? null,
1793
- ultracodeEnabled: (session?.state.runtimeControlValues?.ultracode ?? profileSessionDefaults.ultracode) === 'on',
1794
- });
1795
- await port.publishRuntimeStatus({
1796
- conversationId,
1797
- presentation: payload,
1798
2004
  });
2005
+ await runtimeState.writeRuntimeInfo(conversationId, payload);
1799
2006
  })).catch((error) => {
1800
2007
  console.error('[canon-host] Failed to publish runtime info:', error);
1801
2008
  });
@@ -1805,7 +2012,7 @@ export async function main() {
1805
2012
  runtimeModels = await detectRuntimeModels(workingDir);
1806
2013
  runtimeDescriptor = {
1807
2014
  defaultWorkspaceId: workspaceOptions[0]?.id,
1808
- defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
2015
+ defaultModel: runtimeModels[0]?.value,
1809
2016
  defaultPermissionMode: 'default',
1810
2017
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1811
2018
  availableModels: runtimeModels,
@@ -1813,7 +2020,6 @@ export async function main() {
1813
2020
  availableExecutionModes: hostAvailableExecutionModes,
1814
2021
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1815
2022
  models: runtimeModels,
1816
- defaults: profileSessionDefaults,
1817
2023
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1818
2024
  workspaceRoots: workspaceRootMetadata,
1819
2025
  executionModes: hostAvailableExecutionModes,
@@ -1824,14 +2030,12 @@ export async function main() {
1824
2030
  catch {
1825
2031
  runtimeDescriptor = {
1826
2032
  defaultWorkspaceId: workspaceOptions[0]?.id,
1827
- defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1828
2033
  defaultPermissionMode: 'default',
1829
2034
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1830
2035
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1831
2036
  availableExecutionModes: hostAvailableExecutionModes,
1832
2037
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1833
2038
  models: runtimeModels,
1834
- defaults: profileSessionDefaults,
1835
2039
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1836
2040
  workspaceRoots: workspaceRootMetadata,
1837
2041
  executionModes: hostAvailableExecutionModes,
@@ -1840,12 +2044,14 @@ export async function main() {
1840
2044
  };
1841
2045
  }
1842
2046
  try {
1843
- const convos = await directory.listConversations();
1844
- directory.primeFromStartup(convos);
2047
+ const convos = await client.getConversations();
2048
+ lastKnownConversationRefreshAt = Date.now();
1845
2049
  for (const conversation of convos) {
1846
- port.clearStreaming({ conversationId: conversation.id }).catch(() => { });
1847
- port.clearSessionState({ conversationId: conversation.id }).catch(() => { });
1848
- port.clearTurnState({ conversationId: conversation.id }).catch(() => { });
2050
+ knownConversationIds.add(conversation.id);
2051
+ conversationCache.set(conversation.id, conversation);
2052
+ runtimeState.clearStreaming(conversation.id).catch(() => { });
2053
+ runtimeState.clearSessionState(conversation.id).catch(() => { });
2054
+ runtimeState.clearTurnState(conversation.id).catch(() => { });
1849
2055
  typingSignals.clear(conversation.id).catch(() => { });
1850
2056
  }
1851
2057
  console.error(`[canon-host] ${convos.length} conversations`);
@@ -1853,68 +2059,64 @@ export async function main() {
1853
2059
  catch {
1854
2060
  console.error('[canon-host] Could not load conversations');
1855
2061
  }
1856
- // ── Session manager (generic lifecycle from @canonmsg/agent-host) ──
1857
- const approvalManager = ownerId
1858
- ? new ApprovalManager(createPortApprovalClient(port), agentId, ownerId)
1859
- : null;
1860
- const lifecycle = createSessionLifecycle({
1861
- maxSessions: MAX_SESSIONS,
1862
- idleTimeoutMs: IDLE_TIMEOUT_MS,
1863
- heartbeatMs: HEARTBEAT_MS,
1864
- idleCheckMs: IDLE_CHECK_MS,
1865
- logPrefix: '[canon-host]',
1866
- }, {
1867
- knownConversationIds,
1868
- createSession: (conversationId) => createSessionForConversation(conversationId),
1869
- isSessionRunning: (session) => session.state.state === 'running',
1870
- writeTurnOnHeartbeat: (session) => session.state.state !== 'running' && session.state.state !== 'requires_action',
1871
- writeSessionState: (session) => session.writeState(),
1872
- writeSessionTurn: (session) => session.writeTurn(),
1873
- releaseEnvironment: (session) => releaseConversationEnvironment(session.environment),
1874
- persistResumeState,
1875
- disposeRuntime: (session) => session.query.close(),
1876
- interruptRuntime: (session) => session.query.interrupt(),
1877
- port,
1878
- publishRuntimeHeartbeat,
1879
- onShutdown: async () => {
1880
- detachControlNotifications();
1881
- notifications.detach();
1882
- approvalManager?.dispose();
1883
- await port.clearAgentRuntime().catch(() => { });
1884
- bridge.client.close();
2062
+ // ── Session manager ──
2063
+ const sessions = new Map();
2064
+ const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
2065
+ const pendingSessionCreations = new Map();
2066
+ // Shared /control channel poller (claude host profile): session + signal
2067
+ // keys, sequential conversations, active/idle cadence + jitter sampled at
2068
+ // cycle start, eager baselines at session creation, and nodes left in place
2069
+ // when a handler throws (consumeOnError defaults to false for both keys).
2070
+ const controlPoller = new ControlChannelPoller({
2071
+ rtdb,
2072
+ agentId,
2073
+ conversationIds: () => sessions.keys(),
2074
+ cadence: {
2075
+ kind: 'adaptive',
2076
+ activeMs: CONTROL_POLL_MS,
2077
+ idleMs: IDLE_CONTROL_POLL_MS,
2078
+ jitterMs: CONTROL_POLL_JITTER_MS,
2079
+ hasActiveWork: () => [...sessions.values()].some((session) => !session.closed
2080
+ && (session.state.state === 'running'
2081
+ || session.state.state === 'requires_action'
2082
+ || session.pendingInputs.length > 0
2083
+ || Boolean(session.pendingFinalDelivery))),
2084
+ activitySample: 'cycle-start',
1885
2085
  },
1886
- onShutdownComplete: () => {
1887
- markLocalRuntimeStopped(runtimeId);
1888
- lockHandle?.release();
2086
+ pollOnStart: true,
2087
+ conversationConcurrency: 'sequential',
2088
+ handlers: {
2089
+ session: { handle: applyRuntimeSessionControl },
2090
+ signal: { handle: handleRuntimeControlSignal },
2091
+ },
2092
+ onError: ({ scope, key, conversationId, error }) => {
2093
+ if (scope === 'read')
2094
+ return; // Transient RTDB errors — retry next cycle
2095
+ const convo = conversationId ? ` [${conversationId.slice(0, 8)}]` : '';
2096
+ console.error(`[canon-host]${convo} Control ${key ?? 'poll'} ${scope} error:`, error);
1889
2097
  },
1890
- });
1891
- const { sessions, getOrCreateSession, closeSession, resetRuntimeSession, markSessionPendingInputsRejected, removeQueuedInput, } = lifecycle;
1892
- // Interrupt/stop-clear signal handling (generic shell from @canonmsg/agent-host).
1893
- const handleRuntimeControlSignal = createRuntimeSignalHandler({
1894
- agentId,
1895
- logPrefix: '[canon-host]',
1896
- capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1897
- }, {
1898
- sessions,
1899
- port,
1900
- isSessionRunning: (session) => session.state.state === 'running',
1901
- interruptRuntime: (session) => session.query.interrupt(),
1902
- resetSession: (session) => resetRuntimeSession(session),
1903
- markSessionPendingInputsRejected,
1904
- });
1905
- // Control events arrive as bridge notifications — the bridge owns the ONE
1906
- // authoritative /control consumer for this identity (plan §2); the host's
1907
- // adaptive poller (active/idle cadence, jitter, per-session baselines) is
1908
- // deleted with it.
1909
- const detachControlNotifications = attachHostControlNotifications(notifications.source, { logPrefix: '[canon-host]' }, {
1910
- onSessionControl: applyRuntimeSessionControl,
1911
- onControlSignal: handleRuntimeControlSignal,
1912
2098
  });
1913
2099
  /** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
1914
2100
  const savedSessionIds = new Map();
2101
+ const { getConversationMeta } = createConversationMetadataLoader({
2102
+ client,
2103
+ conversationCache,
2104
+ });
1915
2105
  function resolveWorkspaceIdForBaseCwd(baseCwd) {
1916
2106
  return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
1917
2107
  }
2108
+ async function refreshKnownConversationIds(force = false) {
2109
+ if (!force && Date.now() - lastKnownConversationRefreshAt < HEARTBEAT_MS) {
2110
+ return;
2111
+ }
2112
+ const conversations = await client.getConversations();
2113
+ knownConversationIds.clear();
2114
+ for (const conversation of conversations) {
2115
+ knownConversationIds.add(conversation.id);
2116
+ conversationCache.set(conversation.id, conversation);
2117
+ }
2118
+ lastKnownConversationRefreshAt = Date.now();
2119
+ }
1918
2120
  async function canRequestCanonApproval(conversationId) {
1919
2121
  if (!ownerId)
1920
2122
  return false;
@@ -1922,9 +2124,80 @@ export async function main() {
1922
2124
  .catch(() => conversationCache.get(conversationId) ?? null);
1923
2125
  return Array.isArray(conversation?.memberIds) && conversation.memberIds.includes(ownerId);
1924
2126
  }
1925
- /** Resume-state persistence on session close (claude-specific: SDK sessionId). */
1926
- function persistResumeState(session, saveResume) {
1927
- const conversationId = session.conversationId;
2127
+ function handleConversationUpdated(payload) {
2128
+ const rawMemberIds = payload.changes.memberIds;
2129
+ if (!Array.isArray(rawMemberIds))
2130
+ return;
2131
+ const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
2132
+ const cached = conversationCache.get(payload.conversationId);
2133
+ const membershipChange = payload.membershipChange
2134
+ ?? (cached ? diffCanonMemberIds(cached.memberIds, memberIds) : null);
2135
+ if (cached) {
2136
+ conversationCache.set(payload.conversationId, {
2137
+ ...cached,
2138
+ memberIds,
2139
+ });
2140
+ }
2141
+ if (membershipChange) {
2142
+ pendingMembershipChanges.set(payload.conversationId, membershipChange);
2143
+ }
2144
+ if (!memberIds.includes(agentId)) {
2145
+ knownConversationIds.delete(payload.conversationId);
2146
+ conversationCache.delete(payload.conversationId);
2147
+ }
2148
+ }
2149
+ function getGroupContextMode(conversationId, conversation) {
2150
+ if (conversation?.type !== 'group')
2151
+ return undefined;
2152
+ if (pendingMembershipChanges.has(conversationId))
2153
+ return 'membership_change';
2154
+ if (!promptedGroupContextConversationIds.has(conversationId))
2155
+ return 'initial';
2156
+ return undefined;
2157
+ }
2158
+ function markGroupContextModeUsed(conversationId, mode) {
2159
+ if (!mode)
2160
+ return;
2161
+ promptedGroupContextConversationIds.add(conversationId);
2162
+ if (mode === 'membership_change') {
2163
+ pendingMembershipChanges.delete(conversationId);
2164
+ }
2165
+ }
2166
+ async function loadHydratedInboundContext(input) {
2167
+ const [conversation, page] = await Promise.all([
2168
+ getConversationMeta(input.conversationId),
2169
+ input.hydratedPage
2170
+ ? Promise.resolve(input.hydratedPage)
2171
+ : client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
2172
+ ]);
2173
+ return buildHydratedInboundContext({
2174
+ agentId,
2175
+ conversationId: input.conversationId,
2176
+ conversation,
2177
+ page,
2178
+ activeSelfContextId: input.activeSelfContextId,
2179
+ selfContexts: input.selfContexts,
2180
+ provenance: input.provenance,
2181
+ message: input.message,
2182
+ senderName: input.senderName,
2183
+ isOwner: input.isOwner,
2184
+ ownerId,
2185
+ ownerName,
2186
+ membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
2187
+ groupContextMode: getGroupContextMode(input.conversationId, conversation),
2188
+ });
2189
+ }
2190
+ function closeSession(conversationId, options = {}) {
2191
+ const session = sessions.get(conversationId);
2192
+ if (!session)
2193
+ return;
2194
+ const saveResume = options.saveResume !== false;
2195
+ session.closed = true;
2196
+ if (session.typingKeepaliveTimer) {
2197
+ clearInterval(session.typingKeepaliveTimer);
2198
+ session.typingKeepaliveTimer = null;
2199
+ }
2200
+ releaseConversationEnvironment(session.environment);
1928
2201
  const resumeKey = buildConversationEnvironmentKey(conversationId, session.environment.baseCwd);
1929
2202
  if (saveResume && session.sdkSessionId) {
1930
2203
  savedSessionIds.set(resumeKey, session.sdkSessionId);
@@ -1956,15 +2229,89 @@ export async function main() {
1956
2229
  });
1957
2230
  }
1958
2231
  }
2232
+ session.clearStreaming().catch(() => { });
2233
+ if (session.idleResetTimer) {
2234
+ clearTimeout(session.idleResetTimer);
2235
+ session.idleResetTimer = null;
2236
+ }
2237
+ if (session.finalDeliveryTimer) {
2238
+ clearTimeout(session.finalDeliveryTimer);
2239
+ session.finalDeliveryTimer = null;
2240
+ }
2241
+ runtimeState.clearSessionState(conversationId).catch(() => { });
2242
+ runtimeState.clearTurnState(conversationId).catch(() => { });
2243
+ typingSignals.clear(conversationId).catch(() => { });
2244
+ typingSignals.dispose(conversationId);
2245
+ try {
2246
+ session.query.close();
2247
+ }
2248
+ catch { }
2249
+ sessions.delete(conversationId);
2250
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${options.reason ?? (saveResume ? 'suspended (sessionId saved for resume)' : 'cleared')}`);
1959
2251
  }
1960
- /** Runtime-specific session construction, invoked via the lifecycle's getOrCreateSession. */
1961
- function createSessionForConversation(conversationId) {
1962
- return (async () => {
1963
- const config = await loadSessionConfig(port, conversationId);
1964
- const sessionSettings = resolveEffectiveSessionSettings({
1965
- profileDefaults: profileSessionDefaults,
1966
- sessionConfig: config,
1967
- });
2252
+ async function resetRuntimeSession(session) {
2253
+ const conversationId = session.conversationId;
2254
+ const droppedInputs = session.pendingInputs.splice(0);
2255
+ await markSessionPendingInputsRejected(conversationId, droppedInputs);
2256
+ if (session.activeInput) {
2257
+ session.interruptedTurnKeys.add(session.activeInput.turnKey);
2258
+ }
2259
+ session.pendingFinalText = null;
2260
+ session.pendingFinalDelivery = null;
2261
+ session.turnState = 'interrupted';
2262
+ session.clearStreaming().catch(() => { });
2263
+ typingSignals.clear(conversationId).catch(() => { });
2264
+ if (session.state.state === 'running') {
2265
+ await session.query.interrupt().catch(() => { });
2266
+ }
2267
+ closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
2268
+ }
2269
+ async function markSessionPendingInputsRejected(conversationId, inputs) {
2270
+ await Promise.all(inputs.map((input) => {
2271
+ if (!input.markAccepted || !input.sourceMessageId)
2272
+ return Promise.resolve();
2273
+ return client.updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected').catch(() => { });
2274
+ }));
2275
+ }
2276
+ function removeQueuedInput(conversationId, sourceMessageId) {
2277
+ const session = sessions.get(conversationId);
2278
+ if (!session || session.pendingInputs.length === 0)
2279
+ return;
2280
+ const before = session.pendingInputs.length;
2281
+ session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
2282
+ if (session.pendingInputs.length === before)
2283
+ return;
2284
+ session.writeTurn();
2285
+ }
2286
+ function evictOldestIdle() {
2287
+ let oldest = null;
2288
+ for (const s of sessions.values()) {
2289
+ if (s.state.state === 'running')
2290
+ continue; // Don't evict running sessions
2291
+ if (!oldest || s.lastActivity < oldest.lastActivity)
2292
+ oldest = s;
2293
+ }
2294
+ if (oldest) {
2295
+ console.error(`[canon-host] [${oldest.conversationId.slice(0, 8)}] Evicting idle session`);
2296
+ closeSession(oldest.conversationId);
2297
+ }
2298
+ }
2299
+ async function getOrCreateSession(conversationId) {
2300
+ knownConversationIds.add(conversationId);
2301
+ const existing = sessions.get(conversationId);
2302
+ if (existing && !existing.closed) {
2303
+ existing.lastActivity = Date.now();
2304
+ return existing;
2305
+ }
2306
+ const pending = pendingSessionCreations.get(conversationId);
2307
+ if (pending)
2308
+ return pending;
2309
+ // Evict if at capacity
2310
+ if (sessions.size >= MAX_SESSIONS) {
2311
+ evictOldestIdle();
2312
+ }
2313
+ const creation = (async () => {
2314
+ const config = await loadSessionConfig(conversationId, agentId);
1968
2315
  const sessionExecutionMode = resolveSessionExecutionMode(config);
1969
2316
  const workspaceCwd = resolveWorkspaceCwd(config);
1970
2317
  const environment = prepareConversationEnvironment({
@@ -1981,7 +2328,7 @@ export async function main() {
1981
2328
  executionMode: environment.mode,
1982
2329
  });
1983
2330
  const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
1984
- const session = createSession(conversationId, environment, agentId, port, typingSignals, (convoId, sdkSessionId) => {
2331
+ const session = createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, (convoId, sdkSessionId) => {
1985
2332
  // Called when session processing loop ends — save session ID for resume
1986
2333
  const existing = sessions.get(convoId);
1987
2334
  if (existing) {
@@ -2005,17 +2352,18 @@ export async function main() {
2005
2352
  }
2006
2353
  existing?.clearStreaming().catch(() => { });
2007
2354
  typingSignals.clear(convoId).catch(() => { });
2355
+ typingSignals.dispose(convoId);
2008
2356
  sessions.delete(convoId);
2009
- port.clearSessionState({ conversationId: convoId }).catch(() => { });
2010
- port.clearTurnState({ conversationId: convoId }).catch(() => { });
2357
+ runtimeState.clearSessionState(convoId).catch(() => { });
2358
+ runtimeState.clearTurnState(convoId).catch(() => { });
2011
2359
  }, (descriptor) => {
2012
2360
  runtimeDescriptor = descriptor;
2013
2361
  void publishRuntimeHeartbeat();
2014
2362
  }, {
2015
- model: sessionSettings.model ?? runtimeModels[0]?.value,
2363
+ model: config?.model ?? runtimeModels[0]?.value,
2016
2364
  permissionMode: config?.permissionMode ?? 'default',
2017
- effort: sessionSettings.effort,
2018
- sessionSettings,
2365
+ effort: config?.effort,
2366
+ ultracode: config?.ultracode,
2019
2367
  availableModels: runtimeModels,
2020
2368
  availableExecutionModes: hostAvailableExecutionModes,
2021
2369
  runtimePresentation,
@@ -2023,8 +2371,10 @@ export async function main() {
2023
2371
  canRequestApproval: () => canRequestCanonApproval(conversationId),
2024
2372
  runtimeId,
2025
2373
  ownerId,
2374
+ publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
2026
2375
  }, resumeId);
2027
2376
  sessions.set(conversationId, session);
2377
+ await controlPoller.baseline([conversationId]);
2028
2378
  return session;
2029
2379
  }
2030
2380
  catch (error) {
@@ -2032,6 +2382,13 @@ export async function main() {
2032
2382
  throw error;
2033
2383
  }
2034
2384
  })();
2385
+ pendingSessionCreations.set(conversationId, creation);
2386
+ try {
2387
+ return await creation;
2388
+ }
2389
+ finally {
2390
+ pendingSessionCreations.delete(conversationId);
2391
+ }
2035
2392
  }
2036
2393
  async function enqueueRecoveredInboundMessage(input) {
2037
2394
  const m = input.message;
@@ -2088,9 +2445,7 @@ export async function main() {
2088
2445
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
2089
2446
  console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
2090
2447
  await markQueuedMessageAcceptedForConversation(input.conversationId, m.id ?? null, shouldMarkAccepted);
2091
- await port.sendMessage({
2092
- conversationId: input.conversationId,
2093
- text: `I couldn't start a coding session for this workspace: ${userMessage}`,
2448
+ await sendMessageWithRetry(client, input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
2094
2449
  messageId: `claude-start-failed-${m.id}`,
2095
2450
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2096
2451
  metadata: {
@@ -2133,261 +2488,307 @@ export async function main() {
2133
2488
  });
2134
2489
  }
2135
2490
  }
2136
- try {
2137
- for (const conversation of conversationCache.values()) {
2138
- const cursor = loadRuntimeSessionState(runtimeId, {
2139
- conversationId: conversation.id,
2140
- baseCwd: workingDir,
2141
- })?.lastInboundMessageId;
2142
- const recovery = await collectMissedInboundMessages({
2143
- fetchPage: (before) => port.getMessages({
2144
- conversationId: conversation.id,
2145
- limit: STARTUP_RECOVERY_PAGE_SIZE,
2146
- ...(before ? { before } : {}),
2147
- includeBehavior: true,
2148
- }),
2149
- cursor,
2150
- agentId,
2151
- });
2152
- if (recovery.mode === 'truncated-window') {
2153
- console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Startup recovery cursor not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; recovering truncated window`);
2154
- }
2155
- if (recovery.messages.length > 1) {
2156
- console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Recovered ${recovery.messages.length} missed messages in ${conversation.id}`);
2157
- }
2158
- for (const message of recovery.messages) {
2159
- console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Recovering inbound message on startup`);
2160
- await enqueueRecoveredInboundMessage({
2161
- conversationId: conversation.id,
2162
- message: message,
2163
- hydratedPage: recovery.newestPage,
2164
- });
2165
- }
2166
- }
2167
- }
2168
- catch (error) {
2169
- console.error('[canon-host] Failed startup recovery:', error instanceof Error ? error.message : error);
2170
- }
2171
- // ── Inbound Canon events (bridge notifications) ──
2172
- // The bridge owns SSE reconnect/heartbeat/replay + dedup + self-filtering;
2173
- // HITL control-reply messages (approval_reply / plan_approval_reply) are
2174
- // STRIPPED from onMessage bridge-side — unmatched ones arrive as
2175
- // onControlReply and feed the plan-approval waiters below.
2176
- const handleInboundMessage = (payload) => {
2177
- const m = payload.message;
2178
- if (m.senderId === agentId)
2179
- return;
2180
- const convoId = payload.conversationId;
2181
- knownConversationIds.add(convoId);
2182
- const sender = m.senderName || m.senderId;
2183
- const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2184
- if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2185
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2186
- return;
2187
- }
2188
- void (async () => {
2189
- let materialized = [];
2190
- if (m.id) {
2191
- try {
2192
- materialized = await materializeMessageMedia({
2193
- id: m.id,
2194
- attachments: m.attachments ?? [],
2195
- }, { agentId, conversationId: convoId });
2491
+ // ── Connect SSE stream for inbound Canon messages ──
2492
+ const stream = new CanonStream({
2493
+ apiKey,
2494
+ agentId,
2495
+ handler: {
2496
+ onMessage: (payload) => {
2497
+ const m = payload.message;
2498
+ if (m.senderId === agentId)
2499
+ return;
2500
+ const convoId = payload.conversationId;
2501
+ knownConversationIds.add(convoId);
2502
+ if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
2503
+ const consumed = approvalManager?.handleMessage(convoId, {
2504
+ senderId: m.senderId,
2505
+ metadata: m.metadata,
2506
+ }) ?? false;
2507
+ if (m.metadata?.type === 'approval_reply') {
2508
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
2509
+ return;
2510
+ }
2511
+ if (consumed) {
2512
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
2513
+ return;
2514
+ }
2196
2515
  }
2197
- catch (error) {
2198
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
2516
+ // Check for metadata replies (plan approvals).
2517
+ if (m.metadata?.type === 'plan_approval_reply') {
2518
+ const existingSession = sessions.get(convoId);
2519
+ const pendingReply = existingSession?.pendingReply;
2520
+ const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2521
+ const replyRequestId = m.metadata.planId;
2522
+ if (!pendingReply) {
2523
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
2524
+ return;
2525
+ }
2526
+ if (!isOwnerReply) {
2527
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
2528
+ return;
2529
+ }
2530
+ if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
2531
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
2532
+ return;
2533
+ }
2534
+ if (existingSession?.pendingReply) {
2535
+ const { resolve } = existingSession.pendingReply;
2536
+ existingSession.pendingReply = null;
2537
+ existingSession.lastActivity = Date.now();
2538
+ resolve(m.metadata);
2539
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
2540
+ return;
2541
+ }
2199
2542
  }
2200
- }
2201
- const content = renderInboundContent(m, materialized);
2202
- const hydrated = await loadHydratedInboundContext({
2203
- conversationId: convoId,
2204
- message: m,
2205
- senderName: sender,
2206
- isOwner,
2207
- activeSelfContextId: payload.activeSelfContextId,
2208
- selfContexts: payload.selfContexts,
2209
- provenance: payload.provenance,
2210
- });
2211
- const behavior = payload.behavior ?? hydrated.behavior;
2212
- const activeSelfContextId = hydrated.activeSelfContextId;
2213
- const selfContexts = hydrated.selfContexts;
2214
- const replyMedia = await materializePromptReplyContext({
2215
- replyContext: hydrated.replyContext,
2216
- agentId,
2217
- conversationId: convoId,
2218
- logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
2219
- });
2220
- const replyContext = replyMedia.replyContext;
2221
- const participantContext = hydrated.participantContext;
2222
- const autoReply = payload.turnDispatch?.kind === 'run_turn'
2223
- ? {
2224
- allow: true,
2225
- reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
2543
+ const sender = m.senderName || m.senderId;
2544
+ const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2545
+ if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2546
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2547
+ return;
2226
2548
  }
2227
- : decideAutoReply(participantContext, behavior);
2228
- if (!autoReply.allow) {
2229
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
2230
- return;
2231
- }
2232
- markGroupContextModeUsed(convoId, participantContext.groupContextMode);
2233
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
2234
- const turnMetadata = normalizeTurnMetadata(m.metadata);
2235
- const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
2236
- const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
2237
- let session;
2238
- try {
2239
- session = await getOrCreateSession(convoId);
2240
- }
2241
- catch (error) {
2242
- const message = error instanceof Error ? error.message : String(error);
2243
- const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
2244
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
2245
- await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
2246
- await port.sendMessage({
2247
- conversationId: convoId,
2248
- text: `I couldn't start a coding session for this workspace: ${userMessage}`,
2249
- messageId: `claude-start-failed-${m.id}`,
2250
- ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2251
- metadata: {
2252
- turnSemantics: 'turn_complete',
2253
- replyBehavior: 'suppress_auto_reply',
2254
- },
2255
- }).catch(() => { });
2256
- return;
2257
- }
2258
- session.activeSelfContextId = activeSelfContextId;
2259
- const promptText = buildCanonPrompt({
2260
- content,
2261
- conversationId: convoId,
2262
- participantContext,
2263
- behavior,
2264
- selfContexts,
2265
- activeSelfContextId,
2266
- provenance: hydrated.provenance,
2267
- replyContext,
2268
- message: m,
2269
- });
2270
- const messageContent = await buildCanonUserContent({
2271
- promptText,
2272
- materialized: [...replyMedia.materialized, ...materialized],
2273
- });
2274
- const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
2275
- session.enqueueInbound({
2276
- type: 'user',
2277
- message: {
2278
- role: 'user',
2279
- content: messageContent,
2280
- },
2281
- parent_tool_use_id: null,
2282
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
2283
- if (m.id) {
2284
- saveRuntimeSessionState(runtimeId, {
2285
- conversationId: convoId,
2286
- baseCwd: workingDir,
2287
- lastInboundMessageId: m.id,
2288
- });
2289
- }
2290
- })();
2291
- };
2292
- /**
2293
- * Stripped HITL control replies that matched no bridge record: the plan
2294
- * approval flow (host-raised via a plain sendMessage) resolves its
2295
- * metadata-reply waiter here; anything else is fed to the approval
2296
- * manager as a late/second-chance consume.
2297
- */
2298
- const handleControlReply = (convoId, m) => {
2299
- if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
2300
- const consumed = approvalManager?.handleMessage(convoId, {
2301
- senderId: m.senderId,
2302
- metadata: m.metadata,
2303
- }) ?? false;
2304
- if (m.metadata?.type === 'approval_reply') {
2305
- console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
2306
- return;
2307
- }
2308
- if (consumed) {
2309
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
2310
- return;
2311
- }
2312
- }
2313
- if (m.metadata?.type === 'plan_approval_reply') {
2314
- const existingSession = sessions.get(convoId);
2315
- const pendingReply = existingSession?.pendingReply;
2316
- const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2317
- const replyRequestId = m.metadata.planId;
2318
- if (!pendingReply) {
2319
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
2320
- return;
2321
- }
2322
- if (!isOwnerReply) {
2323
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
2324
- return;
2325
- }
2326
- if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
2327
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
2328
- return;
2329
- }
2330
- if (existingSession?.pendingReply) {
2331
- const { resolve } = existingSession.pendingReply;
2332
- existingSession.pendingReply = null;
2333
- existingSession.lastActivity = Date.now();
2334
- resolve(m.metadata);
2335
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
2336
- }
2337
- }
2338
- };
2339
- notifications.attach({
2340
- onMessage: (params) => {
2341
- handleInboundMessage(params);
2342
- },
2343
- onMessageDeleted: (params) => {
2344
- const payload = params;
2345
- removeQueuedInput(payload.conversationId, payload.messageId);
2346
- },
2347
- onConversationUpdated: (params) => {
2348
- handleConversationUpdated(params);
2349
- },
2350
- onControlReply: (params) => {
2351
- const payload = params;
2352
- handleControlReply(payload.conversationId, payload.message);
2353
- },
2354
- ready: () => {
2355
- streamConnected = true;
2356
- void publishRuntimeHeartbeat();
2357
- console.error('[canon-host] Bridge ready');
2358
- },
2359
- connectionState: (params) => {
2360
- const { upstream } = params;
2361
- if (upstream === 'connected') {
2549
+ void (async () => {
2550
+ let materialized = [];
2551
+ if (m.id) {
2552
+ try {
2553
+ materialized = await materializeMessageMedia({
2554
+ id: m.id,
2555
+ attachments: m.attachments ?? [],
2556
+ }, { agentId, conversationId: convoId });
2557
+ }
2558
+ catch (error) {
2559
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
2560
+ }
2561
+ }
2562
+ const content = renderInboundContent(m, materialized);
2563
+ const hydrated = await loadHydratedInboundContext({
2564
+ conversationId: convoId,
2565
+ message: m,
2566
+ senderName: sender,
2567
+ isOwner,
2568
+ activeSelfContextId: payload.activeSelfContextId,
2569
+ selfContexts: payload.selfContexts,
2570
+ provenance: payload.provenance,
2571
+ });
2572
+ const behavior = payload.behavior ?? hydrated.behavior;
2573
+ const activeSelfContextId = hydrated.activeSelfContextId;
2574
+ const selfContexts = hydrated.selfContexts;
2575
+ const replyMedia = await materializePromptReplyContext({
2576
+ replyContext: hydrated.replyContext,
2577
+ agentId,
2578
+ conversationId: convoId,
2579
+ logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
2580
+ });
2581
+ const replyContext = replyMedia.replyContext;
2582
+ const participantContext = hydrated.participantContext;
2583
+ const autoReply = payload.turnDispatch?.kind === 'run_turn'
2584
+ ? {
2585
+ allow: true,
2586
+ reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
2587
+ }
2588
+ : decideAutoReply(participantContext, behavior);
2589
+ if (!autoReply.allow) {
2590
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
2591
+ return;
2592
+ }
2593
+ markGroupContextModeUsed(convoId, participantContext.groupContextMode);
2594
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
2595
+ const turnMetadata = normalizeTurnMetadata(m.metadata);
2596
+ const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
2597
+ const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
2598
+ let session;
2599
+ try {
2600
+ session = await getOrCreateSession(convoId);
2601
+ }
2602
+ catch (error) {
2603
+ const message = error instanceof Error ? error.message : String(error);
2604
+ const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
2605
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
2606
+ await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
2607
+ await sendMessageWithRetry(client, convoId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
2608
+ messageId: `claude-start-failed-${m.id}`,
2609
+ ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2610
+ metadata: {
2611
+ turnSemantics: 'turn_complete',
2612
+ replyBehavior: 'suppress_auto_reply',
2613
+ },
2614
+ }).catch(() => { });
2615
+ return;
2616
+ }
2617
+ session.activeSelfContextId = activeSelfContextId;
2618
+ const promptText = buildCanonPrompt({
2619
+ content,
2620
+ conversationId: convoId,
2621
+ participantContext,
2622
+ behavior,
2623
+ selfContexts,
2624
+ activeSelfContextId,
2625
+ provenance: hydrated.provenance,
2626
+ replyContext,
2627
+ message: m,
2628
+ });
2629
+ const messageContent = await buildCanonUserContent({
2630
+ promptText,
2631
+ materialized: [...replyMedia.materialized, ...materialized],
2632
+ });
2633
+ const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
2634
+ session.enqueueInbound({
2635
+ type: 'user',
2636
+ message: {
2637
+ role: 'user',
2638
+ content: messageContent,
2639
+ },
2640
+ parent_tool_use_id: null,
2641
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
2642
+ if (m.id) {
2643
+ saveRuntimeSessionState(runtimeId, {
2644
+ conversationId: convoId,
2645
+ baseCwd: workingDir,
2646
+ lastInboundMessageId: m.id,
2647
+ });
2648
+ }
2649
+ })();
2650
+ },
2651
+ onMessageDeleted: (payload) => {
2652
+ removeQueuedInput(payload.conversationId, payload.messageId);
2653
+ },
2654
+ onConversationUpdated: (payload) => {
2655
+ handleConversationUpdated(payload);
2656
+ },
2657
+ onConnected: () => {
2362
2658
  streamConnected = true;
2363
2659
  void publishRuntimeHeartbeat();
2364
- console.error('[canon-host] Canon upstream connected');
2365
- }
2366
- else {
2660
+ console.error('[canon-host] SSE connected');
2661
+ },
2662
+ onDisconnected: () => {
2367
2663
  streamConnected = false;
2368
- port.clearAgentRuntime().catch(() => { });
2369
- console.error(`[canon-host] Canon upstream ${upstream}`);
2370
- }
2664
+ runtimeState.clearAgentRuntime().catch(() => { });
2665
+ console.error('[canon-host] SSE disconnected');
2666
+ },
2667
+ onError: (err) => console.error(`[canon-host] SSE error: ${err.message}`),
2371
2668
  },
2372
2669
  });
2670
+ stream.start().catch((err) => {
2671
+ console.error('[canon-host] SSE start error:', err instanceof Error ? err.message : err);
2672
+ });
2373
2673
  // ── Control signal watcher ──
2374
- // Session control signals (model, mode, effort). The bridge has already
2375
- // consumed the control notification, so every path must publish an answer.
2674
+ // Session control signals (model, mode, effort). A missing/closed session
2675
+ // leaves the node in place; lastSeen still advances, so a stale control is
2676
+ // never replayed once a session exists.
2376
2677
  async function applyRuntimeSessionControl(event) {
2377
2678
  const convoId = event.conversationId;
2378
2679
  const session = sessions.get(convoId);
2379
- if (!session || session.closed) {
2380
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Control received without live session; publishing persisted snapshot`);
2381
- void publishSessionSnapshotsForConversations([convoId]);
2382
- return;
2383
- }
2680
+ if (!session || session.closed)
2681
+ return { consume: false };
2384
2682
  const ctrl = event.control;
2385
2683
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
2386
- applyClaudeSessionControl(session, ctrl);
2684
+ await applyClaudeSessionControl({
2685
+ model: ctrl.model,
2686
+ permissionMode: ctrl.permissionMode,
2687
+ effort: ctrl.effort,
2688
+ ultracode: ctrl.runtimeControlValues?.ultracode,
2689
+ }, {
2690
+ state: session.state,
2691
+ parsePermissionMode: parseClaudePermissionMode,
2692
+ normalizeEffort: normalizeClaudeEffortLevel,
2693
+ parseUltracode: parseClaudeUltracodeMode,
2694
+ setModel: (model) => session.query.setModel(model),
2695
+ setPermissionMode: (mode) => session.query.setPermissionMode(mode),
2696
+ applyEffort: (level) => applyClaudeEffortLevel(session.query, level),
2697
+ applyUltracode: (enabled) => applyClaudeUltracode(session.query, enabled),
2698
+ setRuntimeControlError: (controlId, value, error) => session.setRuntimeControlError(controlId, value, error),
2699
+ clearRuntimeControlError: (controlId) => session.clearRuntimeControlError(controlId),
2700
+ // Publish the app-subscribed snapshot immediately so the control-pending
2701
+ // gate clears now instead of at the next 30s heartbeat (lag fix).
2702
+ publishSnapshot: () => { void publishSessionSnapshots([convoId]); },
2703
+ });
2704
+ session.writeState();
2387
2705
  }
2388
- // ── Heartbeat + idle cleanup + graceful shutdown (agent-host lifecycle) ──
2389
- lifecycle.startTimers();
2390
- lifecycle.installSignalHandlers();
2706
+ // Interrupt/stop-clear signal. Throwing leaves the node in place
2707
+ // (consumeOnError: false); lastSeen has already advanced, so the failed
2708
+ // signal is reported but never retried.
2709
+ async function handleRuntimeControlSignal(event) {
2710
+ const convoId = event.conversationId;
2711
+ const session = sessions.get(convoId);
2712
+ if (!session || session.closed)
2713
+ return { consume: false };
2714
+ if (event.type === 'new_session') {
2715
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
2716
+ await resetRuntimeSession(session);
2717
+ return;
2718
+ }
2719
+ const isActive = session.state.state === 'running';
2720
+ if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
2721
+ return; // Nothing to interrupt — consume the stale signal
2722
+ }
2723
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
2724
+ if (event.type === 'stop_and_drop') {
2725
+ const droppedInputs = session.pendingInputs.splice(0);
2726
+ await markSessionPendingInputsRejected(convoId, droppedInputs);
2727
+ }
2728
+ if (session.activeInput) {
2729
+ session.interruptedTurnKeys.add(session.activeInput.turnKey);
2730
+ }
2731
+ session.pendingFinalText = null;
2732
+ session.turnState = 'interrupted';
2733
+ session.clearStreaming().catch(() => { });
2734
+ typingSignals.clear(convoId).catch(() => { });
2735
+ runtimeState.writeTurnState(convoId, {
2736
+ turnId: session.currentTurnId,
2737
+ state: 'interrupted',
2738
+ queueDepth: session.pendingInputs.length,
2739
+ currentSpeakerId: agentId,
2740
+ lastAcceptedIntent: session.lastAcceptedIntent,
2741
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
2742
+ ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
2743
+ completedAt: { '.sv': 'timestamp' },
2744
+ }).catch(() => { });
2745
+ if (isActive) {
2746
+ await session.query.interrupt();
2747
+ }
2748
+ }
2749
+ controlPoller.start();
2750
+ // ── Heartbeat (per-session) ──
2751
+ const heartbeat = setInterval(() => {
2752
+ for (const session of sessions.values()) {
2753
+ session.writeState();
2754
+ if (session.state.state !== 'running' && session.state.state !== 'requires_action') {
2755
+ session.writeTurn();
2756
+ }
2757
+ }
2758
+ void publishRuntimeHeartbeat();
2759
+ }, HEARTBEAT_MS);
2760
+ // ── Idle session cleanup ──
2761
+ const idleCheck = setInterval(() => {
2762
+ const now = Date.now();
2763
+ for (const convoId of [...sessions.keys()]) {
2764
+ const session = sessions.get(convoId);
2765
+ if (!session || session.state.state === 'running')
2766
+ continue;
2767
+ if (now - session.lastActivity > IDLE_TIMEOUT_MS) {
2768
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Idle timeout`);
2769
+ closeSession(convoId);
2770
+ }
2771
+ }
2772
+ }, IDLE_CHECK_MS);
2773
+ // ── Graceful shutdown ──
2774
+ const shutdown = async () => {
2775
+ console.error('[canon-host] Shutting down...');
2776
+ controlPoller.stop();
2777
+ clearInterval(heartbeat);
2778
+ clearInterval(idleCheck);
2779
+ stream.stop();
2780
+ approvalManager?.dispose();
2781
+ await runtimeState.clearAgentRuntime().catch(() => { });
2782
+ for (const convoId of [...sessions.keys()]) {
2783
+ closeSession(convoId);
2784
+ }
2785
+ markLocalRuntimeStopped(runtimeId);
2786
+ (lockHandle ?? getActiveProfileLock())?.release();
2787
+ process.exit(0);
2788
+ };
2789
+ process.on('SIGINT', shutdown);
2790
+ process.on('SIGTERM', shutdown);
2791
+ process.on('SIGHUP', shutdown);
2391
2792
  console.error('[canon-host] Ready — sessions created on demand');
2392
2793
  // Keep alive
2393
2794
  await new Promise(() => { });
@@ -2396,7 +2797,7 @@ export async function main() {
2396
2797
  runCli(import.meta.url, main, (err) => {
2397
2798
  const message = err instanceof Error ? err.message : String(err);
2398
2799
  console.error(`[canon-host] ${message}`);
2399
- activeLockHandle?.release();
2800
+ getActiveProfileLock()?.release();
2400
2801
  process.exit(1);
2401
2802
  }, {
2402
2803
  name: 'canon-claude',