@canonmsg/claude-code-plugin 0.19.6 → 0.21.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,15 +26,19 @@ 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 { 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';
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';
33
36
  import { runCli } from './cli-entry.js';
34
- import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
35
37
  import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
36
38
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
37
39
  import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
40
+ import { claudeThinkingToFlagValue, claudeThinkingToConfig, parseClaudeSessionDefaults, parseClaudeThinkingMode, parseClaudeUltracodeMode, resolveEffectiveSessionSettings, } from './session-defaults.js';
41
+ import { mapClaudeSubagentActivity } from './subagent-activity.js';
38
42
  function parseRuntimeVisibilityPreset(value) {
39
43
  return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
40
44
  }
@@ -73,6 +77,11 @@ Keep this terminal open while you want Canon to reach the agent. Closing it,
73
77
  logging out, rebooting, or sleeping long enough to stop the process takes the
74
78
  local agent offline until you revive it. Docs:
75
79
  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;
76
85
  let workingDir = process.cwd();
77
86
  let workspaceOptions = [];
78
87
  let workspaceRoots = [];
@@ -80,6 +89,29 @@ let workspaceRootMetadata = [];
80
89
  let runtimeModels = [];
81
90
  const CLAUDE_METADATA_TTL_MS = 5 * 60 * 1000;
82
91
  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
+ ];
83
115
  let runtimeMetadata = {
84
116
  account: null,
85
117
  mcpServers: [],
@@ -204,15 +236,40 @@ function buildClaudeRuntimeDescriptor(input) {
204
236
  RUNTIME_STOP_AND_DROP_ACTION,
205
237
  ];
206
238
  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
+ ];
207
261
  return buildFirstPartyCodingRuntimeDescriptor({
208
262
  clientType: 'claude-code',
209
263
  models: input.models,
264
+ defaultModel: input.defaults?.model ?? input.models[0]?.value ?? null,
210
265
  workspaces: input.workspaces,
211
266
  workspaceRoots: input.workspaceRoots,
212
267
  executionModes: input.executionModes,
213
268
  permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
214
269
  defaultPermissionMode: 'default',
215
270
  effortOptions: [...CLAUDE_EFFORT_OPTIONS],
271
+ defaultEffort: input.defaults?.effort,
272
+ extraRuntimeControls,
216
273
  streamingTextMode: 'delta',
217
274
  turnModes: [
218
275
  {
@@ -300,15 +357,15 @@ function toModelOptions(models) {
300
357
  label: model.displayName,
301
358
  }));
302
359
  }
303
- async function publishAgentRuntime(agentId, runtime) {
304
- await publishHostAgentRuntime(agentId, 'claude-code', runtime);
305
- }
306
- async function loadSessionConfig(conversationId, agentId) {
307
- const config = await loadHostSessionConfig({
308
- conversationId,
309
- agentId,
310
- extraStringFields: ['permissionMode', 'effort'],
360
+ async function publishAgentRuntime(port, runtime) {
361
+ await port.publishHostAgentRuntime({
362
+ clientType: 'claude-code',
363
+ runtime: runtime,
311
364
  });
365
+ }
366
+ async function loadSessionConfig(port, conversationId) {
367
+ const raw = await port.getSessionConfig({ conversationId });
368
+ const config = readHostSessionConfig(raw, ['permissionMode', 'effort']);
312
369
  return {
313
370
  ...config,
314
371
  permissionMode: config?.permissionMode
@@ -423,9 +480,6 @@ async function refreshClaudeRuntimeMetadata(input) {
423
480
  });
424
481
  return runtimeMetadataRefresh;
425
482
  }
426
- function sleep(ms) {
427
- return new Promise((resolve) => setTimeout(resolve, ms));
428
- }
429
483
  function mapClaudeQuestions(value) {
430
484
  if (!Array.isArray(value))
431
485
  return undefined;
@@ -557,13 +611,22 @@ function buildClaudeRuntimeInfo(input) {
557
611
  tier: 'diagnostic',
558
612
  }
559
613
  : null,
560
- runtimeModels[0]
614
+ input.fallbackModel
561
615
  ? {
562
616
  id: 'fallbackModel',
563
617
  label: 'Fallback model',
564
- value: runtimeModels[0].label,
618
+ value: input.fallbackModel,
565
619
  tier: 'diagnostic',
566
- source: 'host-default',
620
+ source: 'applied',
621
+ }
622
+ : null,
623
+ input.ultracodeEnabled
624
+ ? {
625
+ id: 'ultracode',
626
+ label: 'Ultracode',
627
+ value: 'Enabled',
628
+ tier: 'detail',
629
+ source: 'applied',
567
630
  }
568
631
  : null,
569
632
  {
@@ -656,9 +719,6 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
656
719
  const STREAMING_THROTTLE_MS = 250;
657
720
  const HEARTBEAT_MS = 30_000;
658
721
  const IDLE_CHECK_MS = 60_000;
659
- const CONTROL_POLL_MS = 2000;
660
- const IDLE_CONTROL_POLL_MS = 10_000;
661
- const CONTROL_POLL_JITTER_MS = 1_000;
662
722
  const FINAL_DELIVERY_RETRY_MS = 30_000;
663
723
  const MAX_FINAL_DELIVERY_RETRIES = 1;
664
724
  const CLAUDE_RUNTIME_CAPABILITIES = {
@@ -727,7 +787,7 @@ async function buildCanonUserContent(input) {
727
787
  ];
728
788
  }
729
789
  // ── Session factory ─────────────────────────────────────────────────
730
- function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
790
+ function createSession(conversationId, environment, agentId, port, typingSignals, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
731
791
  const { cwd } = environment;
732
792
  const cardResponseUserId = config.ownerId ?? undefined;
733
793
  const cardResponseUser = cardResponseUserId ? { responseUserId: cardResponseUserId } : undefined;
@@ -787,48 +847,15 @@ function createSession(conversationId, environment, agentId, client, typingSigna
787
847
  // eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
788
848
  let session;
789
849
  const REPLY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
790
- function waitForReply(type, requestId) {
791
- // Keep session alive while waiting for user input
792
- session.lastActivity = Date.now();
793
- return new Promise((resolve, reject) => {
794
- const timer = setTimeout(() => {
795
- session.pendingReply = null;
796
- reject(new Error('Timed out waiting for user reply'));
797
- }, REPLY_TIMEOUT_MS);
798
- session.pendingReply = {
799
- type,
800
- requestId,
801
- resolve: (metadata) => {
802
- clearTimeout(timer);
803
- resolve(metadata);
804
- },
805
- };
806
- });
807
- }
808
- async function waitForRuntimeInputResponse(input) {
809
- session.lastActivity = Date.now();
810
- while (Date.now() < input.expiresAt) {
811
- const response = await client.consumeRuntimeInputResponse({
812
- conversationId,
813
- inputId: input.inputId,
814
- }).catch(() => null);
815
- if (response?.status === 'submitted') {
816
- return { status: 'submitted', answers: response.answers, value: response.value };
817
- }
818
- if (response?.status === 'cancelled' || response?.status === 'timeout') {
819
- return { status: response.status };
820
- }
821
- await sleep(1_000);
822
- }
823
- const response = await client.consumeRuntimeInputResponse({
824
- conversationId,
825
- inputId: input.inputId,
826
- }).catch(() => null);
827
- if (response?.status === 'submitted') {
828
- return { status: 'submitted', answers: response.answers, value: response.value };
829
- }
830
- return { status: 'timeout' };
831
- }
850
+ // ── HITL waiters (generic skeleton from @canonmsg/agent-host) ──
851
+ const hitlWaiters = createHitlWaiters({
852
+ conversationId,
853
+ replyTimeoutMs: REPLY_TIMEOUT_MS,
854
+ }, {
855
+ session: () => session,
856
+ port,
857
+ });
858
+ const { waitForReply, waitForRuntimeInputResponse } = hitlWaiters;
832
859
  function buildArtifactMediaMetadata() {
833
860
  return {
834
861
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
@@ -840,6 +867,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
840
867
  };
841
868
  }
842
869
  const claudeCliPath = resolveClaudeCliPath();
870
+ const initialThinkingConfig = claudeThinkingToConfig(config.sessionSettings.thinking);
843
871
  const queryOptions = {
844
872
  systemPrompt: { type: 'preset', preset: 'claude_code' },
845
873
  tools: { type: 'preset', preset: 'claude_code' },
@@ -849,7 +877,16 @@ function createSession(conversationId, environment, agentId, client, typingSigna
849
877
  includePartialMessages: true,
850
878
  maxTurns: 200,
851
879
  ...(config.effort ? { effort: config.effort } : {}),
852
- settings: { forceLoginMethod: 'claudeai' },
880
+ ...(initialThinkingConfig ? { thinking: initialThinkingConfig } : {}),
881
+ ...(config.sessionSettings.fallbackModel ? { fallbackModel: config.sessionSettings.fallbackModel } : {}),
882
+ agentProgressSummaries: config.sessionSettings.agentProgressSummaries ?? true,
883
+ ...(config.sessionSettings.agents ? { agents: config.sessionSettings.agents } : {}),
884
+ settings: {
885
+ forceLoginMethod: 'claudeai',
886
+ ...(config.sessionSettings.ultracode
887
+ ? { ultracode: config.sessionSettings.ultracode === 'on' }
888
+ : {}),
889
+ },
853
890
  settingSources: ['project', 'local'],
854
891
  ...(claudeCliPath ? { pathToClaudeCodeExecutable: claudeCliPath } : {}),
855
892
  stderr: (data) => {
@@ -864,25 +901,27 @@ function createSession(conversationId, environment, agentId, client, typingSigna
864
901
  const questionId = randomUUID();
865
902
  const questions = mapClaudeQuestions(input.questions);
866
903
  const expiresAt = Date.now() + REPLY_TIMEOUT_MS;
867
- await client.createRuntimeInputRequest({
904
+ await port.requestInput({
868
905
  conversationId,
869
906
  inputId: questionId,
870
907
  kind: 'clarify',
871
- expiresAt,
908
+ expiresAt: new Date(expiresAt).toISOString(),
872
909
  title: 'Claude needs input',
873
910
  prompt: questions?.length
874
911
  ? 'Claude needs your input to continue.'
875
912
  : 'Claude needs input to continue.',
876
- ...(questions ? { questions } : {}),
913
+ ...(questions
914
+ ? { questions: questions }
915
+ : { questions: [] }),
877
916
  sensitive: Boolean(questions?.some((question) => question.isSecret)),
878
917
  ...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
879
918
  native: {
880
919
  runtime: 'claude-code',
881
920
  method: 'AskUserQuestion',
882
921
  requestId: questionId,
883
- turnId: session.currentTurnId ?? undefined,
922
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
884
923
  },
885
- turnId: session.currentTurnId ?? undefined,
924
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
886
925
  });
887
926
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
888
927
  const reply = await waitForRuntimeInputResponse({ inputId: questionId, expiresAt });
@@ -937,7 +976,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
937
976
  ...(cardResponseUser ?? {}),
938
977
  ...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
939
978
  });
940
- await sendMessageWithRetry(client, conversationId, planApproval.text, {
979
+ await port.sendMessage({
980
+ conversationId,
981
+ text: planApproval.text,
941
982
  messageId: `claude-plan-${planId}`,
942
983
  ...(session.activeSelfContextId
943
984
  ? { selfContextId: session.activeSelfContextId }
@@ -1057,6 +1098,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1057
1098
  model: undefined,
1058
1099
  permissionMode: config.permissionMode,
1059
1100
  effort: undefined,
1101
+ fallbackModel: config.sessionSettings.fallbackModel,
1102
+ runtimeControlValues: config.sessionSettings.runtimeControlValues,
1060
1103
  state: 'idle',
1061
1104
  },
1062
1105
  availableModels: config.availableModels,
@@ -1090,155 +1133,94 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1090
1133
  setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
1091
1134
  clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
1092
1135
  };
1093
- const streamingOutput = createTurnOutputController({
1094
- turnId: `claude-${conversationId}`,
1095
- mode: 'delta',
1096
- throttleMs: STREAMING_THROTTLE_MS,
1097
- writeSnapshot: (snapshot) => {
1098
- if (session.closed)
1099
- return Promise.resolve();
1100
- const turnId = session.currentTurnId ?? snapshot.turnId;
1101
- session.streamingText = snapshot.text;
1102
- const blocks = snapshot.blocks?.map((block) => ({ ...block, turnId }));
1103
- return runtimeState.writeStreaming(conversationId, {
1104
- text: snapshot.text,
1105
- status: snapshot.status,
1106
- messageId: turnId,
1107
- turnId,
1108
- blocks,
1109
- });
1110
- },
1111
- clearSnapshot: () => {
1112
- session.streamingText = '';
1113
- return runtimeState.clearStreaming(conversationId);
1136
+ // ── Per-session RTDB writers (generic skeleton from @canonmsg/agent-host) ──
1137
+ const writers = createRuntimeWriters({
1138
+ conversationId,
1139
+ agentId,
1140
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1141
+ fallbackTurnId: `claude-${conversationId}`,
1142
+ streamingThrottleMs: STREAMING_THROTTLE_MS,
1143
+ requestedControls: {
1144
+ model: config.model,
1145
+ permissionMode: config.permissionMode,
1146
+ effort: config.effort,
1147
+ runtimeControlValues: config.sessionSettings.runtimeControlValues,
1114
1148
  },
1149
+ }, {
1150
+ session: () => session,
1151
+ port,
1152
+ buildSessionStateExtras: (writerSession) => ({
1153
+ cwd,
1154
+ executionMode: writerSession.environment.mode,
1155
+ ...(writerSession.environment.branch ? { executionBranch: writerSession.environment.branch } : {}),
1156
+ ...(writerSession.environment.worktreePath ? { worktreePath: writerSession.environment.worktreePath } : {}),
1157
+ ...(resolveExecutionFallbackReason(writerSession.environment)
1158
+ ? { executionFallbackReason: resolveExecutionFallbackReason(writerSession.environment) ?? undefined }
1159
+ : {}),
1160
+ }),
1161
+ formatControlError: formatClaudeControlError,
1115
1162
  });
1116
- // ── Per-session streaming helpers ──
1117
- function flushStreaming() {
1118
- streamingOutput.flush().catch(() => { });
1119
- }
1120
- function onStreamDelta(text, segmentId) {
1121
- if (segmentId) {
1122
- streamingOutput.appendTextSegmentDelta(segmentId, text);
1123
- return;
1124
- }
1125
- streamingOutput.appendDelta(text);
1126
- }
1163
+ const { streamingOutput, flushStreaming, onStreamDelta, clearStreaming, getFinalTurnTrail, writeState, writeTurn, setRuntimeControlError, clearRuntimeControlError, refreshVisibleWorkSignal, startVisibleWorkSignal, stopVisibleWorkSignal, } = writers;
1127
1164
  function isActiveTurnInterrupted() {
1128
1165
  const turnKey = session.activeInput?.turnKey;
1129
1166
  return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
1130
1167
  }
1131
- function clearStreaming() {
1132
- return streamingOutput.clear().catch(() => { });
1133
- }
1134
- function getFinalTurnTrail() {
1135
- const turnId = session.currentTurnId ?? `claude-${conversationId}`;
1136
- return streamingOutput.getFinalTrail().map((block) => ({ ...block, turnId }));
1137
- }
1138
- function clearIdleResetTimer() {
1139
- if (!session.idleResetTimer)
1140
- return;
1141
- clearTimeout(session.idleResetTimer);
1142
- session.idleResetTimer = null;
1143
- }
1144
- function clearFinalDeliveryTimer() {
1145
- if (!session.finalDeliveryTimer)
1146
- return;
1147
- clearTimeout(session.finalDeliveryTimer);
1148
- session.finalDeliveryTimer = null;
1149
- }
1150
- function resetTurnToIdle() {
1151
- if (session.closed)
1152
- return;
1153
- clearFinalDeliveryTimer();
1154
- resetClaudeCompletedTurnState(session);
1155
- stopVisibleWorkSignal();
1156
- writeState();
1157
- writeTurn();
1158
- drainPendingInput();
1159
- }
1160
- function scheduleFinalHandoffReset() {
1161
- clearIdleResetTimer();
1162
- session.idleResetTimer = setTimeout(() => {
1163
- session.idleResetTimer = null;
1164
- typingSignals.clear(conversationId).catch(() => { });
1165
- clearStreaming();
1166
- resetTurnToIdle();
1167
- }, FINAL_MESSAGE_HANDOFF_MS);
1168
- }
1169
- function markPendingFinalDelivery(finalText, turn) {
1170
- clearIdleResetTimer();
1171
- const retryCount = session.pendingFinalDelivery?.turnKey === turn.turnKey
1172
- ? session.pendingFinalDelivery.retryCount
1173
- : 0;
1174
- session.pendingFinalText = finalText;
1175
- session.pendingFinalDelivery = {
1176
- turnKey: turn.turnKey,
1177
- text: finalText,
1178
- retryCount,
1179
- messageId: buildClaudeFinalMessageId({
1180
- agentId,
1181
- conversationId,
1182
- turnKey: turn.turnKey,
1183
- }),
1184
- };
1185
- session.state.state = 'running';
1186
- session.turnState = 'streaming';
1187
- stopVisibleWorkSignal();
1188
- streamingOutput.replaceSnapshot(finalText, 'streaming').catch(() => { });
1189
- writeState();
1190
- writeTurn();
1191
- typingSignals.start(conversationId, 'typing').catch(() => { });
1192
- }
1193
- async function deliverFinalReply(finalText, turn = session.activeInput) {
1194
- if (!shouldDeliverClaudeFinal({
1168
+ // ── Turn queue + final delivery (generic skeleton from @canonmsg/agent-host) ──
1169
+ const turnQueue = createTurnQueue({ conversationId }, {
1170
+ session: () => session,
1171
+ isSessionRunning: (queueSession) => queueSession.state.state === 'running',
1172
+ markSessionRunning: (queueSession) => { queueSession.state.state = 'running'; },
1173
+ prepareInput: (input) => prepareArtifactsForInput(input),
1174
+ sendInput: (input) => sendInput(input),
1175
+ interruptRuntime: (queueSession) => queueSession.query.interrupt(),
1176
+ port,
1177
+ writers: {
1178
+ writeTurn,
1179
+ stopVisibleWorkSignal,
1180
+ clearStreaming,
1181
+ },
1182
+ });
1183
+ const { startCanonInput, drainPendingInput, markQueuedMessageAccepted } = turnQueue;
1184
+ const finalDelivery = createFinalDelivery({
1185
+ conversationId,
1186
+ logPrefix: '[canon-host]',
1187
+ delivery: { mode: 'retry', retryMs: FINAL_DELIVERY_RETRY_MS, maxRetries: MAX_FINAL_DELIVERY_RETRIES },
1188
+ }, {
1189
+ session: () => session,
1190
+ port,
1191
+ typingSignals,
1192
+ writers: {
1193
+ writeState,
1194
+ writeTurn,
1195
+ stopVisibleWorkSignal,
1196
+ clearStreaming,
1197
+ getFinalTurnTrail,
1198
+ replaceStreamingSnapshot: (text, status) => streamingOutput.replaceSnapshot(text, status),
1199
+ },
1200
+ resetCompletedTurnState: (deliverySession) => resetClaudeCompletedTurnState(deliverySession),
1201
+ shouldDeliverFinal: (turn, deliverySession) => shouldDeliverClaudeFinal({
1195
1202
  turn,
1196
- finalizedTurnKeys: session.finalizedTurnKeys,
1197
- interruptedTurnKeys: session.interruptedTurnKeys,
1198
- })) {
1199
- return true;
1200
- }
1201
- const deliverTurn = turn;
1202
- if (!deliverTurn)
1203
- return true;
1204
- const finalMessageId = buildClaudeFinalMessageId({
1205
- agentId,
1203
+ finalizedTurnKeys: deliverySession.finalizedTurnKeys,
1204
+ interruptedTurnKeys: deliverySession.interruptedTurnKeys,
1205
+ }),
1206
+ buildFinalMessageId: (turnKey) => buildClaudeFinalMessageId({ agentId, conversationId, turnKey }),
1207
+ drainPendingInput: () => drainPendingInput(),
1208
+ });
1209
+ const { clearIdleResetTimer, clearFinalDeliveryTimer, resetTurnToIdle, scheduleFinalHandoffReset, markPendingFinalDelivery, deliverFinalReply, scheduleFinalDeliveryRetry, } = finalDelivery;
1210
+ async function sendTurnArtifactFile(file) {
1211
+ // Byte transfer is bridge-owned (§B8): hand the bridge a path, it streams
1212
+ // the upload; then send the permanent message with the attachment ref.
1213
+ const uploaded = await port.uploadMedia({
1214
+ path: file.path,
1215
+ mime: inferUploadMimeType(file.path),
1216
+ fileName: file.fileName,
1206
1217
  conversationId,
1207
- turnKey: deliverTurn.turnKey,
1208
1218
  });
1209
- try {
1210
- await sendMessageWithRetry(client, conversationId, finalText, {
1211
- messageId: finalMessageId,
1212
- ...(session.activeSelfContextId
1213
- ? { selfContextId: session.activeSelfContextId }
1214
- : {}),
1215
- metadata: {
1216
- turnId: session.currentTurnId,
1217
- turnKey: deliverTurn.turnKey,
1218
- sourceMessageId: deliverTurn.sourceMessageId ?? undefined,
1219
- turnSemantics: 'turn_complete',
1220
- deliveryIntent: session.lastAcceptedIntent ?? undefined,
1221
- ...(getFinalTurnTrail().length > 0
1222
- ? { turnTrail: getFinalTurnTrail() }
1223
- : {}),
1224
- },
1225
- });
1226
- session.finalizedTurnKeys.add(deliverTurn.turnKey);
1227
- session.state.state = 'idle';
1228
- session.pendingFinalDelivery = null;
1229
- clearFinalDeliveryTimer();
1230
- writeState();
1231
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars)`);
1232
- return true;
1233
- }
1234
- catch (err) {
1235
- markPendingFinalDelivery(finalText, deliverTurn);
1236
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
1237
- return false;
1238
- }
1239
- }
1240
- function sendTurnArtifactFile(file) {
1241
- return sendMediaFileMessage(client, conversationId, file.path, '', {
1219
+ return port.sendMessage({
1220
+ conversationId,
1221
+ text: '',
1222
+ contentType: uploaded.attachment.kind,
1223
+ attachments: [uploaded.attachment],
1242
1224
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1243
1225
  metadata: buildArtifactMediaMetadata(),
1244
1226
  });
@@ -1275,138 +1257,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1275
1257
  await routeWorkspaceGeneratedArtifacts(input);
1276
1258
  }
1277
1259
  }
1278
- function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
1279
- if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
1280
- return;
1281
- if (pending.retryCount >= MAX_FINAL_DELIVERY_RETRIES) {
1282
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply delivery exhausted after bounded retries`);
1283
- session.pendingFinalDelivery = null;
1284
- resetTurnToIdle();
1285
- return;
1286
- }
1287
- pending.retryCount += 1;
1288
- session.pendingFinalDelivery = pending;
1289
- clearFinalDeliveryTimer();
1290
- session.finalDeliveryTimer = setTimeout(() => {
1291
- session.finalDeliveryTimer = null;
1292
- const retryTurn = session.activeInput?.turnKey === pending.turnKey
1293
- ? session.activeInput
1294
- : null;
1295
- void deliverFinalReply(pending.text, retryTurn).then((sent) => {
1296
- if (sent) {
1297
- scheduleFinalHandoffReset();
1298
- return;
1299
- }
1300
- scheduleFinalDeliveryRetry();
1301
- });
1302
- }, FINAL_DELIVERY_RETRY_MS);
1303
- }
1304
- function writeState() {
1305
- const runtimeControlErrors = Object.keys(session.runtimeControlErrors).length > 0
1306
- ? session.runtimeControlErrors
1307
- : null;
1308
- const controlState = {};
1309
- const appliedAt = Date.now();
1310
- if (session.state.model !== undefined) {
1311
- controlState.model = { value: session.state.model, source: 'applied', appliedAt };
1312
- }
1313
- else if (config.model !== undefined) {
1314
- controlState.model = { value: config.model, source: 'requested' };
1315
- }
1316
- if (session.state.permissionMode !== undefined) {
1317
- controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
1318
- }
1319
- else if (config.permissionMode !== undefined) {
1320
- controlState.permissionMode = { value: config.permissionMode, source: 'requested' };
1321
- }
1322
- if (session.state.effort !== undefined) {
1323
- controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
1324
- }
1325
- else if (config.effort !== undefined) {
1326
- controlState.effort = { value: config.effort, source: 'requested' };
1327
- }
1328
- runtimeState.writeSessionState(conversationId, {
1329
- model: session.state.model,
1330
- permissionMode: session.state.permissionMode,
1331
- effort: session.state.effort,
1332
- controlState,
1333
- runtimeControlErrors,
1334
- contextUsage: session.state.contextUsage,
1335
- ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
1336
- cwd,
1337
- executionMode: session.environment.mode,
1338
- ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
1339
- ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
1340
- ...(resolveExecutionFallbackReason(session.environment)
1341
- ? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
1342
- : {}),
1343
- hostMode: true,
1344
- isActive: true,
1345
- }).catch(() => { });
1346
- }
1347
- function setRuntimeControlError(controlId, value, error) {
1348
- session.runtimeControlErrors = {
1349
- ...session.runtimeControlErrors,
1350
- [controlId]: {
1351
- value,
1352
- message: formatClaudeControlError(error),
1353
- updatedAt: Date.now(),
1354
- },
1355
- };
1356
- writeState();
1357
- }
1358
- function clearRuntimeControlError(controlId) {
1359
- if (!(controlId in session.runtimeControlErrors))
1360
- return;
1361
- const next = { ...session.runtimeControlErrors };
1362
- delete next[controlId];
1363
- session.runtimeControlErrors = next;
1364
- writeState();
1365
- }
1366
- function writeTurn() {
1367
- const isOpenTurn = session.turnState === 'thinking'
1368
- || session.turnState === 'streaming'
1369
- || session.turnState === 'tool'
1370
- || session.turnState === 'waiting_input';
1371
- if (isOpenTurn) {
1372
- session.currentTurnUpdatedAt = Date.now();
1373
- }
1374
- runtimeState.writeTurnState(conversationId, {
1375
- turnId: session.currentTurnId,
1376
- state: session.turnState,
1377
- queueDepth: session.pendingInputs.length,
1378
- currentSpeakerId: agentId,
1379
- lastAcceptedIntent: session.lastAcceptedIntent,
1380
- capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1381
- ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
1382
- ...(isOpenTurn && session.currentTurnUpdatedAt ? { turnUpdatedAt: session.currentTurnUpdatedAt } : {}),
1383
- ...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
1384
- ? { completedAt: { '.sv': 'timestamp' } }
1385
- : {}),
1386
- }).catch(() => { });
1387
- }
1388
- function refreshVisibleWorkSignal() {
1389
- if (session.closed)
1390
- return;
1391
- if (session.turnState !== 'thinking' && session.turnState !== 'tool')
1392
- return;
1393
- typingSignals.start(conversationId, 'thinking').catch(() => { });
1394
- }
1395
- function startVisibleWorkSignal() {
1396
- refreshVisibleWorkSignal();
1397
- }
1398
- function stopVisibleWorkSignal() {
1399
- if (session.typingKeepaliveTimer) {
1400
- clearInterval(session.typingKeepaliveTimer);
1401
- session.typingKeepaliveTimer = null;
1402
- }
1403
- typingSignals.clear(conversationId).catch(() => { });
1404
- }
1405
- async function markQueuedMessageAccepted(sourceMessageId, markAccepted) {
1406
- if (!markAccepted || !sourceMessageId)
1407
- return;
1408
- await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
1409
- }
1410
1260
  async function prepareArtifactsForInput(input) {
1411
1261
  if (input.kind !== 'canon')
1412
1262
  return;
@@ -1422,25 +1272,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1422
1272
  return;
1423
1273
  }
1424
1274
  }
1425
- async function startCanonInput(input) {
1426
- await markQueuedMessageAccepted(input.sourceMessageId, input.markAccepted);
1427
- await prepareArtifactsForInput(input);
1428
- session.state.state = 'running';
1429
- sendInput(input);
1430
- }
1431
- function drainPendingInput() {
1432
- if (session.closed || session.state.state === 'running' || session.pendingInputs.length === 0)
1433
- return;
1434
- const next = session.pendingInputs.shift();
1435
- if (!next)
1436
- return;
1437
- session.lastAcceptedIntent = next.intent;
1438
- writeTurn();
1439
- void startCanonInput(next);
1440
- }
1441
1275
  enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
1442
- session.lastActivity = Date.now();
1443
- const input = createClaudeInputEnvelope({
1276
+ turnQueue.enqueueCanonInput(createClaudeInputEnvelope({
1444
1277
  kind: 'canon',
1445
1278
  msg,
1446
1279
  intent,
@@ -1448,35 +1281,11 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1448
1281
  markAccepted,
1449
1282
  isOwnerTurn,
1450
1283
  artifactRoutingMode,
1451
- });
1452
- if (session.pendingFinalDelivery) {
1453
- session.pendingInputs.push(input);
1454
- writeTurn();
1455
- return;
1456
- }
1457
- if (session.state.state === 'running' && intent !== 'interrupt') {
1458
- session.pendingInputs.push(input);
1459
- writeTurn();
1460
- return;
1461
- }
1462
- if (session.state.state === 'running' && intent === 'interrupt') {
1463
- if (session.activeInput) {
1464
- session.interruptedTurnKeys.add(session.activeInput.turnKey);
1465
- }
1466
- session.pendingInputs.unshift(input);
1467
- session.turnState = 'interrupted';
1468
- stopVisibleWorkSignal();
1469
- clearStreaming().catch(() => { });
1470
- writeTurn();
1471
- void session.query.interrupt().catch(() => { });
1472
- return;
1473
- }
1474
- session.lastAcceptedIntent = intent;
1475
- writeTurn();
1476
- void startCanonInput(input);
1284
+ }));
1477
1285
  };
1478
1286
  writeTurn();
1479
1287
  // ── Process SDK output messages ──
1288
+ let assistantStreamOrdinal = 0;
1480
1289
  const processMessages = async () => {
1481
1290
  try {
1482
1291
  for await (const msg of q) {
@@ -1507,7 +1316,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1507
1316
  stopVisibleWorkSignal();
1508
1317
  break;
1509
1318
  }
1510
- if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
1319
+ if (event?.type === 'message_start') {
1320
+ assistantStreamOrdinal += 1;
1321
+ }
1322
+ else if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
1511
1323
  session.toolInProgress = true;
1512
1324
  session.turnState = 'tool';
1513
1325
  streamingOutput.addBlock({
@@ -1536,12 +1348,35 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1536
1348
  stopVisibleWorkSignal();
1537
1349
  writeTurn();
1538
1350
  const blockIndex = typeof event.index === 'number' ? event.index : 'latest';
1539
- onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${blockIndex}`);
1351
+ const messageOrdinal = assistantStreamOrdinal || 1;
1352
+ onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${messageOrdinal}:${blockIndex}`);
1540
1353
  }
1541
1354
  break;
1542
1355
  }
1543
1356
  case 'system': {
1544
1357
  const subtype = msg.subtype;
1358
+ if (subtype === 'task_started'
1359
+ || subtype === 'task_progress'
1360
+ || subtype === 'task_updated'
1361
+ || subtype === 'task_notification') {
1362
+ const activity = mapClaudeSubagentActivity(msg);
1363
+ if (activity && !isActiveTurnInterrupted()) {
1364
+ if (!session.currentTurnId) {
1365
+ session.currentTurnId = randomUUID();
1366
+ session.currentTurnOpenedAt = Date.now();
1367
+ }
1368
+ if (session.state.state === 'running' && session.turnState !== 'streaming') {
1369
+ session.turnState = 'thinking';
1370
+ startVisibleWorkSignal();
1371
+ writeTurn();
1372
+ }
1373
+ await streamingOutput.addBlock(activity).catch(() => { });
1374
+ }
1375
+ }
1376
+ if (subtype === 'model_refusal_fallback') {
1377
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Model refusal fallback: `
1378
+ + `${msg.original_model} → ${msg.fallback_model}`);
1379
+ }
1545
1380
  if (subtype === 'session_state_changed') {
1546
1381
  const state = msg.state;
1547
1382
  session.state.state = state;
@@ -1553,6 +1388,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1553
1388
  }
1554
1389
  session.state.contextUsage = undefined;
1555
1390
  session.pendingFinalText = null;
1391
+ assistantStreamOrdinal = 0;
1556
1392
  session.toolInProgress = false;
1557
1393
  session.turnState = 'thinking';
1558
1394
  startVisibleWorkSignal();
@@ -1693,13 +1529,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1693
1529
  runtimeModels = modelList;
1694
1530
  onRuntimeDescriptorUpdate({
1695
1531
  defaultWorkspaceId: workspaceOptions[0]?.id,
1696
- defaultModel: modelList[0]?.value,
1532
+ defaultModel: config.sessionSettings.model ?? modelList[0]?.value,
1697
1533
  defaultPermissionMode: 'default',
1698
1534
  availableModels: modelList,
1699
1535
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1700
1536
  availableExecutionModes: config.availableExecutionModes,
1701
1537
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1702
1538
  models: modelList,
1539
+ defaults: config.sessionSettings,
1703
1540
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1704
1541
  workspaceRoots: workspaceRootMetadata,
1705
1542
  executionModes: config.availableExecutionModes,
@@ -1752,41 +1589,57 @@ export async function main() {
1752
1589
  for (const warning of workspaceDiscovery.warnings) {
1753
1590
  console.error(`[canon-host] ${warning}`);
1754
1591
  }
1755
- const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
1592
+ const { agentName: profileAgentName, profile, sessionDefaults: rawSessionDefaults, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
1593
+ const profileSessionDefaults = parseClaudeSessionDefaults(rawSessionDefaults);
1594
+ activeLockHandle = lockHandle ?? null;
1756
1595
  console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
1757
- // ── Canon API client + RTDB auth ──
1758
- const client = new CanonClient(apiKey, baseUrl);
1759
- const rtdb = initRTDBAuth(client);
1760
- const typingSignals = createTypingStatusPublisher({
1761
- setTyping: (conversationId, typing, status) => status
1762
- ? client.setTyping(conversationId, typing, status)
1763
- : client.setTyping(conversationId, typing),
1596
+ // ── Canon via the Bridge (bridge-execution-plan Phase 6) ──
1597
+ // One connectBridge per host process: the daemon owns the SSE stream,
1598
+ // RTDB auth, control polling, dedup, and reconnect. Notification handlers
1599
+ // must exist BEFORE the hello response lands (replayed frames dispatch
1600
+ // synchronously), so a buffered source subscribes inside `configure` and
1601
+ // the real handlers attach after the session machinery is built.
1602
+ const notifications = createBufferedNotificationSource([
1603
+ 'ready',
1604
+ 'connectionState',
1605
+ 'onMessage',
1606
+ 'onMessageDeleted',
1607
+ 'onConversationUpdated',
1608
+ 'onSessionControl',
1609
+ 'onControlSignal',
1610
+ 'onControlReply',
1611
+ ]);
1612
+ const bridge = await connectBridge({
1613
+ ...(profile ? { profile } : {}),
1614
+ mode: 'auto',
1615
+ ...(resolvePackagedBridgeBin() ? { binPath: resolvePackagedBridgeBin() } : {}),
1616
+ hello: {
1617
+ clientType: 'claude-code',
1618
+ wantFamilies: ['messages', 'runtime_turn'],
1619
+ },
1620
+ configure: (bridgeClient) => notifications.connect(bridgeClient),
1621
+ });
1622
+ // The BridgeClient's zod-derived result shapes mirror the core wire types
1623
+ // structurally; the port pins the strong types the host machinery uses.
1624
+ const port = bridge.client;
1625
+ bridge.client.onProtocolError((error) => {
1626
+ console.error(`[canon-host] Bridge protocol error: ${error.message}`);
1764
1627
  });
1628
+ const typingSignals = createPortTypingSignals(port);
1765
1629
  async function markQueuedMessageAcceptedForConversation(conversationId, sourceMessageId, markAccepted) {
1766
1630
  if (!markAccepted || !sourceMessageId)
1767
1631
  return;
1768
- await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
1769
- }
1770
- let agentId;
1771
- let ownerId = null;
1772
- let ownerName = null;
1773
- try {
1774
- const ctx = await client.getAgentMe();
1775
- agentId = ctx.agentId;
1776
- ownerId = ctx.ownerId;
1777
- ownerName = ctx.ownerName;
1778
- console.error(`[canon-host] Connected as ${ctx.displayName || agentId}`);
1779
- }
1780
- catch {
1781
- if (profileAgentId) {
1782
- agentId = profileAgentId;
1783
- }
1784
- else {
1785
- const auth = await client.getAuthToken();
1786
- agentId = auth.agentId;
1787
- }
1788
- console.error(`[canon-host] Authenticated as ${agentId}`);
1632
+ await port.setDisposition({
1633
+ conversationId,
1634
+ messageId: sourceMessageId,
1635
+ inboundDisposition: 'accepted_now',
1636
+ }).catch(() => { });
1789
1637
  }
1638
+ const agentId = bridge.hello.agentId;
1639
+ const ownerId = bridge.hello.agentContext.ownerId ?? null;
1640
+ const ownerName = bridge.hello.agentContext.ownerName ?? null;
1641
+ console.error(`[canon-host] Connected as ${bridge.hello.agentName || agentId} `
1642
+ + `(canon-bridge${bridge.spawnedDaemon ? ' spawned' : ''} at ${bridge.socketPath})`);
1790
1643
  const launchArgs = [...process.argv.slice(2)];
1791
1644
  if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
1792
1645
  launchArgs.push('--cwd', workingDir);
@@ -1815,11 +1668,6 @@ export async function main() {
1815
1668
  lastStartedAt: new Date().toISOString(),
1816
1669
  lastHeartbeatAt: new Date().toISOString(),
1817
1670
  });
1818
- const runtimeState = createRuntimeStatePublisher({
1819
- agentId,
1820
- clientType: 'claude-code',
1821
- hostMode: true,
1822
- });
1823
1671
  let streamConnected = false;
1824
1672
  const hostAvailableExecutionModes = [
1825
1673
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1832,23 +1680,28 @@ export async function main() {
1832
1680
  });
1833
1681
  let runtimeDescriptor = {
1834
1682
  defaultWorkspaceId: workspaceOptions[0]?.id,
1683
+ defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1835
1684
  defaultPermissionMode: 'default',
1836
1685
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1837
1686
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1838
1687
  availableExecutionModes: hostAvailableExecutionModes,
1839
1688
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1840
1689
  models: runtimeModels,
1690
+ defaults: profileSessionDefaults,
1841
1691
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1842
1692
  workspaceRoots: workspaceRootMetadata,
1843
1693
  executionModes: hostAvailableExecutionModes,
1844
1694
  presentation: runtimePresentation,
1845
1695
  }),
1846
1696
  };
1847
- const conversationCache = new Map();
1848
- const knownConversationIds = new Set();
1849
- const promptedGroupContextConversationIds = new Set();
1850
- const pendingMembershipChanges = new Map();
1851
- let lastKnownConversationRefreshAt = 0;
1697
+ // ── Conversation directory (shared inbound-ingestion bookkeeping from @canonmsg/agent-host) ──
1698
+ const directory = createConversationDirectory({
1699
+ agentId,
1700
+ ownerId,
1701
+ ownerName,
1702
+ refreshFloorMs: HEARTBEAT_MS,
1703
+ }, { port });
1704
+ const { conversationCache, knownConversationIds, getConversationMeta, refreshKnownConversationIds, handleConversationUpdated, markGroupContextModeUsed, loadHydratedInboundContext, } = directory;
1852
1705
  const publishRuntimeHeartbeat = async () => {
1853
1706
  heartbeatLocalRuntimeEntry(runtimeId, {
1854
1707
  agentId,
@@ -1869,18 +1722,17 @@ export async function main() {
1869
1722
  console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
1870
1723
  return runtimeMetadata;
1871
1724
  });
1872
- await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
1725
+ await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
1873
1726
  console.error('[canon-host] Failed to publish agent runtime:', error);
1874
1727
  });
1875
- await publishHostSessionSnapshots({
1728
+ await port.publishHostSessionSnapshots({
1876
1729
  conversationIds: Array.from(knownConversationIds),
1877
- agentId,
1878
1730
  clientType: 'claude-code',
1879
1731
  runtime: runtimeDescriptor,
1880
- workspaceOptions,
1732
+ workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
1881
1733
  defaultCwd: workingDir,
1882
1734
  extraSessionConfigFields: ['permissionMode', 'effort'],
1883
- liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
1735
+ liveSessionConfigByConversation: Object.fromEntries(Array.from(sessions.values()).map((session) => {
1884
1736
  const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
1885
1737
  return [
1886
1738
  session.conversationId,
@@ -1888,6 +1740,9 @@ export async function main() {
1888
1740
  ...(session.state.model ? { model: session.state.model } : {}),
1889
1741
  ...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
1890
1742
  ...(session.state.effort ? { effort: session.state.effort } : {}),
1743
+ ...(session.state.runtimeControlValues
1744
+ ? { runtimeControlValues: session.state.runtimeControlValues }
1745
+ : {}),
1891
1746
  ...(workspaceId ? { workspaceId } : {}),
1892
1747
  executionMode: session.environment.mode,
1893
1748
  executionBranch: session.environment.branch ?? null,
@@ -1903,8 +1758,10 @@ export async function main() {
1903
1758
  if (runtimeDescriptor.runtimeDescriptor) {
1904
1759
  runtimeDescriptor = {
1905
1760
  ...runtimeDescriptor,
1761
+ defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1906
1762
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1907
1763
  models: runtimeModels,
1764
+ defaults: profileSessionDefaults,
1908
1765
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1909
1766
  workspaceRoots: workspaceRootMetadata,
1910
1767
  executionModes: hostAvailableExecutionModes,
@@ -1932,8 +1789,15 @@ export async function main() {
1932
1789
  executionBranch: session?.environment.branch ?? null,
1933
1790
  worktreePath: session?.environment.worktreePath ?? null,
1934
1791
  fallbackReason: resolveExecutionFallbackReason(session?.environment),
1792
+ fallbackModel: session?.state.fallbackModel
1793
+ ?? profileSessionDefaults.fallbackModel
1794
+ ?? null,
1795
+ ultracodeEnabled: (session?.state.runtimeControlValues?.ultracode ?? profileSessionDefaults.ultracode) === 'on',
1796
+ });
1797
+ await port.publishRuntimeStatus({
1798
+ conversationId,
1799
+ presentation: payload,
1935
1800
  });
1936
- await runtimeState.writeRuntimeInfo(conversationId, payload);
1937
1801
  })).catch((error) => {
1938
1802
  console.error('[canon-host] Failed to publish runtime info:', error);
1939
1803
  });
@@ -1943,7 +1807,7 @@ export async function main() {
1943
1807
  runtimeModels = await detectRuntimeModels(workingDir);
1944
1808
  runtimeDescriptor = {
1945
1809
  defaultWorkspaceId: workspaceOptions[0]?.id,
1946
- defaultModel: runtimeModels[0]?.value,
1810
+ defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1947
1811
  defaultPermissionMode: 'default',
1948
1812
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1949
1813
  availableModels: runtimeModels,
@@ -1951,6 +1815,7 @@ export async function main() {
1951
1815
  availableExecutionModes: hostAvailableExecutionModes,
1952
1816
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1953
1817
  models: runtimeModels,
1818
+ defaults: profileSessionDefaults,
1954
1819
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1955
1820
  workspaceRoots: workspaceRootMetadata,
1956
1821
  executionModes: hostAvailableExecutionModes,
@@ -1961,12 +1826,14 @@ export async function main() {
1961
1826
  catch {
1962
1827
  runtimeDescriptor = {
1963
1828
  defaultWorkspaceId: workspaceOptions[0]?.id,
1829
+ defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1964
1830
  defaultPermissionMode: 'default',
1965
1831
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1966
1832
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1967
1833
  availableExecutionModes: hostAvailableExecutionModes,
1968
1834
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1969
1835
  models: runtimeModels,
1836
+ defaults: profileSessionDefaults,
1970
1837
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1971
1838
  workspaceRoots: workspaceRootMetadata,
1972
1839
  executionModes: hostAvailableExecutionModes,
@@ -1975,14 +1842,12 @@ export async function main() {
1975
1842
  };
1976
1843
  }
1977
1844
  try {
1978
- const convos = await client.getConversations();
1979
- lastKnownConversationRefreshAt = Date.now();
1845
+ const convos = await directory.listConversations();
1846
+ directory.primeFromStartup(convos);
1980
1847
  for (const conversation of convos) {
1981
- knownConversationIds.add(conversation.id);
1982
- conversationCache.set(conversation.id, conversation);
1983
- runtimeState.clearStreaming(conversation.id).catch(() => { });
1984
- runtimeState.clearSessionState(conversation.id).catch(() => { });
1985
- runtimeState.clearTurnState(conversation.id).catch(() => { });
1848
+ port.clearStreaming({ conversationId: conversation.id }).catch(() => { });
1849
+ port.clearSessionState({ conversationId: conversation.id }).catch(() => { });
1850
+ port.clearTurnState({ conversationId: conversation.id }).catch(() => { });
1986
1851
  typingSignals.clear(conversation.id).catch(() => { });
1987
1852
  }
1988
1853
  console.error(`[canon-host] ${convos.length} conversations`);
@@ -1990,64 +1855,68 @@ export async function main() {
1990
1855
  catch {
1991
1856
  console.error('[canon-host] Could not load conversations');
1992
1857
  }
1993
- // ── Session manager ──
1994
- const sessions = new Map();
1995
- const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
1996
- const pendingSessionCreations = new Map();
1997
- // Shared /control channel poller (claude host profile): session + signal
1998
- // keys, sequential conversations, active/idle cadence + jitter sampled at
1999
- // cycle start, eager baselines at session creation, and nodes left in place
2000
- // when a handler throws (consumeOnError defaults to false for both keys).
2001
- const controlPoller = new ControlChannelPoller({
2002
- rtdb,
2003
- agentId,
2004
- conversationIds: () => sessions.keys(),
2005
- cadence: {
2006
- kind: 'adaptive',
2007
- activeMs: CONTROL_POLL_MS,
2008
- idleMs: IDLE_CONTROL_POLL_MS,
2009
- jitterMs: CONTROL_POLL_JITTER_MS,
2010
- hasActiveWork: () => [...sessions.values()].some((session) => !session.closed
2011
- && (session.state.state === 'running'
2012
- || session.state.state === 'requires_action'
2013
- || session.pendingInputs.length > 0
2014
- || Boolean(session.pendingFinalDelivery))),
2015
- activitySample: 'cycle-start',
2016
- },
2017
- pollOnStart: true,
2018
- conversationConcurrency: 'sequential',
2019
- handlers: {
2020
- session: { handle: applyRuntimeSessionControl },
2021
- signal: { handle: handleRuntimeControlSignal },
1858
+ // ── Session manager (generic lifecycle from @canonmsg/agent-host) ──
1859
+ const approvalManager = ownerId
1860
+ ? new ApprovalManager(createPortApprovalClient(port), agentId, ownerId)
1861
+ : null;
1862
+ const lifecycle = createSessionLifecycle({
1863
+ maxSessions: MAX_SESSIONS,
1864
+ idleTimeoutMs: IDLE_TIMEOUT_MS,
1865
+ heartbeatMs: HEARTBEAT_MS,
1866
+ idleCheckMs: IDLE_CHECK_MS,
1867
+ logPrefix: '[canon-host]',
1868
+ }, {
1869
+ knownConversationIds,
1870
+ createSession: (conversationId) => createSessionForConversation(conversationId),
1871
+ isSessionRunning: (session) => session.state.state === 'running',
1872
+ writeTurnOnHeartbeat: (session) => session.state.state !== 'running' && session.state.state !== 'requires_action',
1873
+ writeSessionState: (session) => session.writeState(),
1874
+ writeSessionTurn: (session) => session.writeTurn(),
1875
+ releaseEnvironment: (session) => releaseConversationEnvironment(session.environment),
1876
+ persistResumeState,
1877
+ disposeRuntime: (session) => session.query.close(),
1878
+ interruptRuntime: (session) => session.query.interrupt(),
1879
+ port,
1880
+ publishRuntimeHeartbeat,
1881
+ onShutdown: async () => {
1882
+ detachControlNotifications();
1883
+ notifications.detach();
1884
+ approvalManager?.dispose();
1885
+ await port.clearAgentRuntime().catch(() => { });
1886
+ bridge.client.close();
2022
1887
  },
2023
- onError: ({ scope, key, conversationId, error }) => {
2024
- if (scope === 'read')
2025
- return; // Transient RTDB errors — retry next cycle
2026
- const convo = conversationId ? ` [${conversationId.slice(0, 8)}]` : '';
2027
- console.error(`[canon-host]${convo} Control ${key ?? 'poll'} ${scope} error:`, error);
1888
+ onShutdownComplete: () => {
1889
+ markLocalRuntimeStopped(runtimeId);
1890
+ lockHandle?.release();
2028
1891
  },
2029
1892
  });
1893
+ const { sessions, getOrCreateSession, closeSession, resetRuntimeSession, markSessionPendingInputsRejected, removeQueuedInput, } = lifecycle;
1894
+ // Interrupt/stop-clear signal handling (generic shell from @canonmsg/agent-host).
1895
+ const handleRuntimeControlSignal = createRuntimeSignalHandler({
1896
+ agentId,
1897
+ logPrefix: '[canon-host]',
1898
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1899
+ }, {
1900
+ sessions,
1901
+ port,
1902
+ isSessionRunning: (session) => session.state.state === 'running',
1903
+ interruptRuntime: (session) => session.query.interrupt(),
1904
+ resetSession: (session) => resetRuntimeSession(session),
1905
+ markSessionPendingInputsRejected,
1906
+ });
1907
+ // Control events arrive as bridge notifications — the bridge owns the ONE
1908
+ // authoritative /control consumer for this identity (plan §2); the host's
1909
+ // adaptive poller (active/idle cadence, jitter, per-session baselines) is
1910
+ // deleted with it.
1911
+ const detachControlNotifications = attachHostControlNotifications(notifications.source, { logPrefix: '[canon-host]' }, {
1912
+ onSessionControl: applyRuntimeSessionControl,
1913
+ onControlSignal: handleRuntimeControlSignal,
1914
+ });
2030
1915
  /** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
2031
1916
  const savedSessionIds = new Map();
2032
- const { getConversationMeta } = createConversationMetadataLoader({
2033
- client,
2034
- conversationCache,
2035
- });
2036
1917
  function resolveWorkspaceIdForBaseCwd(baseCwd) {
2037
1918
  return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
2038
1919
  }
2039
- async function refreshKnownConversationIds(force = false) {
2040
- if (!force && Date.now() - lastKnownConversationRefreshAt < HEARTBEAT_MS) {
2041
- return;
2042
- }
2043
- const conversations = await client.getConversations();
2044
- knownConversationIds.clear();
2045
- for (const conversation of conversations) {
2046
- knownConversationIds.add(conversation.id);
2047
- conversationCache.set(conversation.id, conversation);
2048
- }
2049
- lastKnownConversationRefreshAt = Date.now();
2050
- }
2051
1920
  async function canRequestCanonApproval(conversationId) {
2052
1921
  if (!ownerId)
2053
1922
  return false;
@@ -2055,80 +1924,9 @@ export async function main() {
2055
1924
  .catch(() => conversationCache.get(conversationId) ?? null);
2056
1925
  return Array.isArray(conversation?.memberIds) && conversation.memberIds.includes(ownerId);
2057
1926
  }
2058
- function handleConversationUpdated(payload) {
2059
- const rawMemberIds = payload.changes.memberIds;
2060
- if (!Array.isArray(rawMemberIds))
2061
- return;
2062
- const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
2063
- const cached = conversationCache.get(payload.conversationId);
2064
- const membershipChange = payload.membershipChange
2065
- ?? (cached ? diffCanonMemberIds(cached.memberIds, memberIds) : null);
2066
- if (cached) {
2067
- conversationCache.set(payload.conversationId, {
2068
- ...cached,
2069
- memberIds,
2070
- });
2071
- }
2072
- if (membershipChange) {
2073
- pendingMembershipChanges.set(payload.conversationId, membershipChange);
2074
- }
2075
- if (!memberIds.includes(agentId)) {
2076
- knownConversationIds.delete(payload.conversationId);
2077
- conversationCache.delete(payload.conversationId);
2078
- }
2079
- }
2080
- function getGroupContextMode(conversationId, conversation) {
2081
- if (conversation?.type !== 'group')
2082
- return undefined;
2083
- if (pendingMembershipChanges.has(conversationId))
2084
- return 'membership_change';
2085
- if (!promptedGroupContextConversationIds.has(conversationId))
2086
- return 'initial';
2087
- return undefined;
2088
- }
2089
- function markGroupContextModeUsed(conversationId, mode) {
2090
- if (!mode)
2091
- return;
2092
- promptedGroupContextConversationIds.add(conversationId);
2093
- if (mode === 'membership_change') {
2094
- pendingMembershipChanges.delete(conversationId);
2095
- }
2096
- }
2097
- async function loadHydratedInboundContext(input) {
2098
- const [conversation, page] = await Promise.all([
2099
- getConversationMeta(input.conversationId),
2100
- input.hydratedPage
2101
- ? Promise.resolve(input.hydratedPage)
2102
- : client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
2103
- ]);
2104
- return buildHydratedInboundContext({
2105
- agentId,
2106
- conversationId: input.conversationId,
2107
- conversation,
2108
- page,
2109
- activeSelfContextId: input.activeSelfContextId,
2110
- selfContexts: input.selfContexts,
2111
- provenance: input.provenance,
2112
- message: input.message,
2113
- senderName: input.senderName,
2114
- isOwner: input.isOwner,
2115
- ownerId,
2116
- ownerName,
2117
- membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
2118
- groupContextMode: getGroupContextMode(input.conversationId, conversation),
2119
- });
2120
- }
2121
- function closeSession(conversationId, options = {}) {
2122
- const session = sessions.get(conversationId);
2123
- if (!session)
2124
- return;
2125
- const saveResume = options.saveResume !== false;
2126
- session.closed = true;
2127
- if (session.typingKeepaliveTimer) {
2128
- clearInterval(session.typingKeepaliveTimer);
2129
- session.typingKeepaliveTimer = null;
2130
- }
2131
- releaseConversationEnvironment(session.environment);
1927
+ /** Resume-state persistence on session close (claude-specific: SDK sessionId). */
1928
+ function persistResumeState(session, saveResume) {
1929
+ const conversationId = session.conversationId;
2132
1930
  const resumeKey = buildConversationEnvironmentKey(conversationId, session.environment.baseCwd);
2133
1931
  if (saveResume && session.sdkSessionId) {
2134
1932
  savedSessionIds.set(resumeKey, session.sdkSessionId);
@@ -2160,89 +1958,15 @@ export async function main() {
2160
1958
  });
2161
1959
  }
2162
1960
  }
2163
- session.clearStreaming().catch(() => { });
2164
- if (session.idleResetTimer) {
2165
- clearTimeout(session.idleResetTimer);
2166
- session.idleResetTimer = null;
2167
- }
2168
- if (session.finalDeliveryTimer) {
2169
- clearTimeout(session.finalDeliveryTimer);
2170
- session.finalDeliveryTimer = null;
2171
- }
2172
- runtimeState.clearSessionState(conversationId).catch(() => { });
2173
- runtimeState.clearTurnState(conversationId).catch(() => { });
2174
- typingSignals.clear(conversationId).catch(() => { });
2175
- typingSignals.dispose(conversationId);
2176
- try {
2177
- session.query.close();
2178
- }
2179
- catch { }
2180
- sessions.delete(conversationId);
2181
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${options.reason ?? (saveResume ? 'suspended (sessionId saved for resume)' : 'cleared')}`);
2182
- }
2183
- async function resetRuntimeSession(session) {
2184
- const conversationId = session.conversationId;
2185
- const droppedInputs = session.pendingInputs.splice(0);
2186
- await markSessionPendingInputsRejected(conversationId, droppedInputs);
2187
- if (session.activeInput) {
2188
- session.interruptedTurnKeys.add(session.activeInput.turnKey);
2189
- }
2190
- session.pendingFinalText = null;
2191
- session.pendingFinalDelivery = null;
2192
- session.turnState = 'interrupted';
2193
- session.clearStreaming().catch(() => { });
2194
- typingSignals.clear(conversationId).catch(() => { });
2195
- if (session.state.state === 'running') {
2196
- await session.query.interrupt().catch(() => { });
2197
- }
2198
- closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
2199
- }
2200
- async function markSessionPendingInputsRejected(conversationId, inputs) {
2201
- await Promise.all(inputs.map((input) => {
2202
- if (!input.markAccepted || !input.sourceMessageId)
2203
- return Promise.resolve();
2204
- return client.updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected').catch(() => { });
2205
- }));
2206
1961
  }
2207
- function removeQueuedInput(conversationId, sourceMessageId) {
2208
- const session = sessions.get(conversationId);
2209
- if (!session || session.pendingInputs.length === 0)
2210
- return;
2211
- const before = session.pendingInputs.length;
2212
- session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
2213
- if (session.pendingInputs.length === before)
2214
- return;
2215
- session.writeTurn();
2216
- }
2217
- function evictOldestIdle() {
2218
- let oldest = null;
2219
- for (const s of sessions.values()) {
2220
- if (s.state.state === 'running')
2221
- continue; // Don't evict running sessions
2222
- if (!oldest || s.lastActivity < oldest.lastActivity)
2223
- oldest = s;
2224
- }
2225
- if (oldest) {
2226
- console.error(`[canon-host] [${oldest.conversationId.slice(0, 8)}] Evicting idle session`);
2227
- closeSession(oldest.conversationId);
2228
- }
2229
- }
2230
- async function getOrCreateSession(conversationId) {
2231
- knownConversationIds.add(conversationId);
2232
- const existing = sessions.get(conversationId);
2233
- if (existing && !existing.closed) {
2234
- existing.lastActivity = Date.now();
2235
- return existing;
2236
- }
2237
- const pending = pendingSessionCreations.get(conversationId);
2238
- if (pending)
2239
- return pending;
2240
- // Evict if at capacity
2241
- if (sessions.size >= MAX_SESSIONS) {
2242
- evictOldestIdle();
2243
- }
2244
- const creation = (async () => {
2245
- const config = await loadSessionConfig(conversationId, agentId);
1962
+ /** Runtime-specific session construction, invoked via the lifecycle's getOrCreateSession. */
1963
+ function createSessionForConversation(conversationId) {
1964
+ return (async () => {
1965
+ const config = await loadSessionConfig(port, conversationId);
1966
+ const sessionSettings = resolveEffectiveSessionSettings({
1967
+ profileDefaults: profileSessionDefaults,
1968
+ sessionConfig: config,
1969
+ });
2246
1970
  const sessionExecutionMode = resolveSessionExecutionMode(config);
2247
1971
  const workspaceCwd = resolveWorkspaceCwd(config);
2248
1972
  const environment = prepareConversationEnvironment({
@@ -2259,7 +1983,7 @@ export async function main() {
2259
1983
  executionMode: environment.mode,
2260
1984
  });
2261
1985
  const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
2262
- const session = createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, (convoId, sdkSessionId) => {
1986
+ const session = createSession(conversationId, environment, agentId, port, typingSignals, (convoId, sdkSessionId) => {
2263
1987
  // Called when session processing loop ends — save session ID for resume
2264
1988
  const existing = sessions.get(convoId);
2265
1989
  if (existing) {
@@ -2283,17 +2007,17 @@ export async function main() {
2283
2007
  }
2284
2008
  existing?.clearStreaming().catch(() => { });
2285
2009
  typingSignals.clear(convoId).catch(() => { });
2286
- typingSignals.dispose(convoId);
2287
2010
  sessions.delete(convoId);
2288
- runtimeState.clearSessionState(convoId).catch(() => { });
2289
- runtimeState.clearTurnState(convoId).catch(() => { });
2011
+ port.clearSessionState({ conversationId: convoId }).catch(() => { });
2012
+ port.clearTurnState({ conversationId: convoId }).catch(() => { });
2290
2013
  }, (descriptor) => {
2291
2014
  runtimeDescriptor = descriptor;
2292
2015
  void publishRuntimeHeartbeat();
2293
2016
  }, {
2294
- model: config?.model ?? runtimeModels[0]?.value,
2017
+ model: sessionSettings.model ?? runtimeModels[0]?.value,
2295
2018
  permissionMode: config?.permissionMode ?? 'default',
2296
- effort: config?.effort,
2019
+ effort: sessionSettings.effort,
2020
+ sessionSettings,
2297
2021
  availableModels: runtimeModels,
2298
2022
  availableExecutionModes: hostAvailableExecutionModes,
2299
2023
  runtimePresentation,
@@ -2303,7 +2027,6 @@ export async function main() {
2303
2027
  ownerId,
2304
2028
  }, resumeId);
2305
2029
  sessions.set(conversationId, session);
2306
- await controlPoller.baseline([conversationId]);
2307
2030
  return session;
2308
2031
  }
2309
2032
  catch (error) {
@@ -2311,13 +2034,6 @@ export async function main() {
2311
2034
  throw error;
2312
2035
  }
2313
2036
  })();
2314
- pendingSessionCreations.set(conversationId, creation);
2315
- try {
2316
- return await creation;
2317
- }
2318
- finally {
2319
- pendingSessionCreations.delete(conversationId);
2320
- }
2321
2037
  }
2322
2038
  async function enqueueRecoveredInboundMessage(input) {
2323
2039
  const m = input.message;
@@ -2374,7 +2090,9 @@ export async function main() {
2374
2090
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
2375
2091
  console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
2376
2092
  await markQueuedMessageAcceptedForConversation(input.conversationId, m.id ?? null, shouldMarkAccepted);
2377
- await sendMessageWithRetry(client, input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
2093
+ await port.sendMessage({
2094
+ conversationId: input.conversationId,
2095
+ text: `I couldn't start a coding session for this workspace: ${userMessage}`,
2378
2096
  messageId: `claude-start-failed-${m.id}`,
2379
2097
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2380
2098
  metadata: {
@@ -2424,7 +2142,12 @@ export async function main() {
2424
2142
  baseCwd: workingDir,
2425
2143
  })?.lastInboundMessageId;
2426
2144
  const recovery = await collectMissedInboundMessages({
2427
- fetchPage: (before) => client.getMessagesPage(conversation.id, STARTUP_RECOVERY_PAGE_SIZE, before),
2145
+ fetchPage: (before) => port.getMessages({
2146
+ conversationId: conversation.id,
2147
+ limit: STARTUP_RECOVERY_PAGE_SIZE,
2148
+ ...(before ? { before } : {}),
2149
+ includeBehavior: true,
2150
+ }),
2428
2151
  cursor,
2429
2152
  agentId,
2430
2153
  });
@@ -2447,188 +2170,208 @@ export async function main() {
2447
2170
  catch (error) {
2448
2171
  console.error('[canon-host] Failed startup recovery:', error instanceof Error ? error.message : error);
2449
2172
  }
2450
- // ── Connect SSE stream for inbound Canon messages ──
2451
- const stream = new CanonStream({
2452
- apiKey,
2453
- agentId,
2454
- handler: {
2455
- onMessage: (payload) => {
2456
- const m = payload.message;
2457
- if (m.senderId === agentId)
2458
- return;
2459
- const convoId = payload.conversationId;
2460
- knownConversationIds.add(convoId);
2461
- if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
2462
- const consumed = approvalManager?.handleMessage(convoId, {
2463
- senderId: m.senderId,
2464
- metadata: m.metadata,
2465
- }) ?? false;
2466
- if (m.metadata?.type === 'approval_reply') {
2467
- console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
2468
- return;
2469
- }
2470
- if (consumed) {
2471
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
2472
- return;
2473
- }
2173
+ // ── Inbound Canon events (bridge notifications) ──
2174
+ // The bridge owns SSE reconnect/heartbeat/replay + dedup + self-filtering;
2175
+ // HITL control-reply messages (approval_reply / plan_approval_reply) are
2176
+ // STRIPPED from onMessage bridge-side — unmatched ones arrive as
2177
+ // onControlReply and feed the plan-approval waiters below.
2178
+ const handleInboundMessage = (payload) => {
2179
+ const m = payload.message;
2180
+ if (m.senderId === agentId)
2181
+ return;
2182
+ const convoId = payload.conversationId;
2183
+ knownConversationIds.add(convoId);
2184
+ const sender = m.senderName || m.senderId;
2185
+ const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2186
+ if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2187
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2188
+ return;
2189
+ }
2190
+ void (async () => {
2191
+ let materialized = [];
2192
+ if (m.id) {
2193
+ try {
2194
+ materialized = await materializeMessageMedia({
2195
+ id: m.id,
2196
+ attachments: m.attachments ?? [],
2197
+ }, { agentId, conversationId: convoId });
2474
2198
  }
2475
- // Check for metadata replies (plan approvals).
2476
- if (m.metadata?.type === 'plan_approval_reply') {
2477
- const existingSession = sessions.get(convoId);
2478
- const pendingReply = existingSession?.pendingReply;
2479
- const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2480
- const replyRequestId = m.metadata.planId;
2481
- if (!pendingReply) {
2482
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
2483
- return;
2484
- }
2485
- if (!isOwnerReply) {
2486
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
2487
- return;
2488
- }
2489
- if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
2490
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
2491
- return;
2492
- }
2493
- if (existingSession?.pendingReply) {
2494
- const { resolve } = existingSession.pendingReply;
2495
- existingSession.pendingReply = null;
2496
- existingSession.lastActivity = Date.now();
2497
- resolve(m.metadata);
2498
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
2499
- return;
2500
- }
2199
+ catch (error) {
2200
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
2501
2201
  }
2502
- const sender = m.senderName || m.senderId;
2503
- const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2504
- if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2505
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2506
- return;
2202
+ }
2203
+ const content = renderInboundContent(m, materialized);
2204
+ const hydrated = await loadHydratedInboundContext({
2205
+ conversationId: convoId,
2206
+ message: m,
2207
+ senderName: sender,
2208
+ isOwner,
2209
+ activeSelfContextId: payload.activeSelfContextId,
2210
+ selfContexts: payload.selfContexts,
2211
+ provenance: payload.provenance,
2212
+ });
2213
+ const behavior = payload.behavior ?? hydrated.behavior;
2214
+ const activeSelfContextId = hydrated.activeSelfContextId;
2215
+ const selfContexts = hydrated.selfContexts;
2216
+ const replyMedia = await materializePromptReplyContext({
2217
+ replyContext: hydrated.replyContext,
2218
+ agentId,
2219
+ conversationId: convoId,
2220
+ logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
2221
+ });
2222
+ const replyContext = replyMedia.replyContext;
2223
+ const participantContext = hydrated.participantContext;
2224
+ const autoReply = payload.turnDispatch?.kind === 'run_turn'
2225
+ ? {
2226
+ allow: true,
2227
+ reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
2507
2228
  }
2508
- void (async () => {
2509
- let materialized = [];
2510
- if (m.id) {
2511
- try {
2512
- materialized = await materializeMessageMedia({
2513
- id: m.id,
2514
- attachments: m.attachments ?? [],
2515
- }, { agentId, conversationId: convoId });
2516
- }
2517
- catch (error) {
2518
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
2519
- }
2520
- }
2521
- const content = renderInboundContent(m, materialized);
2522
- const hydrated = await loadHydratedInboundContext({
2523
- conversationId: convoId,
2524
- message: m,
2525
- senderName: sender,
2526
- isOwner,
2527
- activeSelfContextId: payload.activeSelfContextId,
2528
- selfContexts: payload.selfContexts,
2529
- provenance: payload.provenance,
2530
- });
2531
- const behavior = payload.behavior ?? hydrated.behavior;
2532
- const activeSelfContextId = hydrated.activeSelfContextId;
2533
- const selfContexts = hydrated.selfContexts;
2534
- const replyMedia = await materializePromptReplyContext({
2535
- replyContext: hydrated.replyContext,
2536
- agentId,
2537
- conversationId: convoId,
2538
- logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
2539
- });
2540
- const replyContext = replyMedia.replyContext;
2541
- const participantContext = hydrated.participantContext;
2542
- const autoReply = payload.turnDispatch?.kind === 'run_turn'
2543
- ? {
2544
- allow: true,
2545
- reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
2546
- }
2547
- : decideAutoReply(participantContext, behavior);
2548
- if (!autoReply.allow) {
2549
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
2550
- return;
2551
- }
2552
- markGroupContextModeUsed(convoId, participantContext.groupContextMode);
2553
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
2554
- const turnMetadata = normalizeTurnMetadata(m.metadata);
2555
- const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
2556
- const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
2557
- let session;
2558
- try {
2559
- session = await getOrCreateSession(convoId);
2560
- }
2561
- catch (error) {
2562
- const message = error instanceof Error ? error.message : String(error);
2563
- const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
2564
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
2565
- await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
2566
- await sendMessageWithRetry(client, convoId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
2567
- messageId: `claude-start-failed-${m.id}`,
2568
- ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2569
- metadata: {
2570
- turnSemantics: 'turn_complete',
2571
- replyBehavior: 'suppress_auto_reply',
2572
- },
2573
- }).catch(() => { });
2574
- return;
2575
- }
2576
- session.activeSelfContextId = activeSelfContextId;
2577
- const promptText = buildCanonPrompt({
2578
- content,
2579
- conversationId: convoId,
2580
- participantContext,
2581
- behavior,
2582
- selfContexts,
2583
- activeSelfContextId,
2584
- provenance: hydrated.provenance,
2585
- replyContext,
2586
- message: m,
2587
- });
2588
- const messageContent = await buildCanonUserContent({
2589
- promptText,
2590
- materialized: [...replyMedia.materialized, ...materialized],
2591
- });
2592
- const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
2593
- session.enqueueInbound({
2594
- type: 'user',
2595
- message: {
2596
- role: 'user',
2597
- content: messageContent,
2598
- },
2599
- parent_tool_use_id: null,
2600
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
2601
- if (m.id) {
2602
- saveRuntimeSessionState(runtimeId, {
2603
- conversationId: convoId,
2604
- baseCwd: workingDir,
2605
- lastInboundMessageId: m.id,
2606
- });
2607
- }
2608
- })();
2609
- },
2610
- onMessageDeleted: (payload) => {
2611
- removeQueuedInput(payload.conversationId, payload.messageId);
2612
- },
2613
- onConversationUpdated: (payload) => {
2614
- handleConversationUpdated(payload);
2615
- },
2616
- onConnected: () => {
2229
+ : decideAutoReply(participantContext, behavior);
2230
+ if (!autoReply.allow) {
2231
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
2232
+ return;
2233
+ }
2234
+ markGroupContextModeUsed(convoId, participantContext.groupContextMode);
2235
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
2236
+ const turnMetadata = normalizeTurnMetadata(m.metadata);
2237
+ const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
2238
+ const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
2239
+ let session;
2240
+ try {
2241
+ session = await getOrCreateSession(convoId);
2242
+ }
2243
+ catch (error) {
2244
+ const message = error instanceof Error ? error.message : String(error);
2245
+ const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
2246
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
2247
+ await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
2248
+ await port.sendMessage({
2249
+ conversationId: convoId,
2250
+ text: `I couldn't start a coding session for this workspace: ${userMessage}`,
2251
+ messageId: `claude-start-failed-${m.id}`,
2252
+ ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2253
+ metadata: {
2254
+ turnSemantics: 'turn_complete',
2255
+ replyBehavior: 'suppress_auto_reply',
2256
+ },
2257
+ }).catch(() => { });
2258
+ return;
2259
+ }
2260
+ session.activeSelfContextId = activeSelfContextId;
2261
+ const promptText = buildCanonPrompt({
2262
+ content,
2263
+ conversationId: convoId,
2264
+ participantContext,
2265
+ behavior,
2266
+ selfContexts,
2267
+ activeSelfContextId,
2268
+ provenance: hydrated.provenance,
2269
+ replyContext,
2270
+ message: m,
2271
+ });
2272
+ const messageContent = await buildCanonUserContent({
2273
+ promptText,
2274
+ materialized: [...replyMedia.materialized, ...materialized],
2275
+ });
2276
+ const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
2277
+ session.enqueueInbound({
2278
+ type: 'user',
2279
+ message: {
2280
+ role: 'user',
2281
+ content: messageContent,
2282
+ },
2283
+ parent_tool_use_id: null,
2284
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
2285
+ if (m.id) {
2286
+ saveRuntimeSessionState(runtimeId, {
2287
+ conversationId: convoId,
2288
+ baseCwd: workingDir,
2289
+ lastInboundMessageId: m.id,
2290
+ });
2291
+ }
2292
+ })();
2293
+ };
2294
+ /**
2295
+ * Stripped HITL control replies that matched no bridge record: the plan
2296
+ * approval flow (host-raised via a plain sendMessage) resolves its
2297
+ * metadata-reply waiter here; anything else is fed to the approval
2298
+ * manager as a late/second-chance consume.
2299
+ */
2300
+ const handleControlReply = (convoId, m) => {
2301
+ if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
2302
+ const consumed = approvalManager?.handleMessage(convoId, {
2303
+ senderId: m.senderId,
2304
+ metadata: m.metadata,
2305
+ }) ?? false;
2306
+ if (m.metadata?.type === 'approval_reply') {
2307
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
2308
+ return;
2309
+ }
2310
+ if (consumed) {
2311
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
2312
+ return;
2313
+ }
2314
+ }
2315
+ if (m.metadata?.type === 'plan_approval_reply') {
2316
+ const existingSession = sessions.get(convoId);
2317
+ const pendingReply = existingSession?.pendingReply;
2318
+ const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2319
+ const replyRequestId = m.metadata.planId;
2320
+ if (!pendingReply) {
2321
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
2322
+ return;
2323
+ }
2324
+ if (!isOwnerReply) {
2325
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
2326
+ return;
2327
+ }
2328
+ if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
2329
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
2330
+ return;
2331
+ }
2332
+ if (existingSession?.pendingReply) {
2333
+ const { resolve } = existingSession.pendingReply;
2334
+ existingSession.pendingReply = null;
2335
+ existingSession.lastActivity = Date.now();
2336
+ resolve(m.metadata);
2337
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
2338
+ }
2339
+ }
2340
+ };
2341
+ notifications.attach({
2342
+ onMessage: (params) => {
2343
+ handleInboundMessage(params);
2344
+ },
2345
+ onMessageDeleted: (params) => {
2346
+ const payload = params;
2347
+ removeQueuedInput(payload.conversationId, payload.messageId);
2348
+ },
2349
+ onConversationUpdated: (params) => {
2350
+ handleConversationUpdated(params);
2351
+ },
2352
+ onControlReply: (params) => {
2353
+ const payload = params;
2354
+ handleControlReply(payload.conversationId, payload.message);
2355
+ },
2356
+ ready: () => {
2357
+ streamConnected = true;
2358
+ void publishRuntimeHeartbeat();
2359
+ console.error('[canon-host] Bridge ready');
2360
+ },
2361
+ connectionState: (params) => {
2362
+ const { upstream } = params;
2363
+ if (upstream === 'connected') {
2617
2364
  streamConnected = true;
2618
2365
  void publishRuntimeHeartbeat();
2619
- console.error('[canon-host] SSE connected');
2620
- },
2621
- onDisconnected: () => {
2366
+ console.error('[canon-host] Canon upstream connected');
2367
+ }
2368
+ else {
2622
2369
  streamConnected = false;
2623
- runtimeState.clearAgentRuntime().catch(() => { });
2624
- console.error('[canon-host] SSE disconnected');
2625
- },
2626
- onError: (err) => console.error(`[canon-host] SSE error: ${err.message}`),
2370
+ port.clearAgentRuntime().catch(() => { });
2371
+ console.error(`[canon-host] Canon upstream ${upstream}`);
2372
+ }
2627
2373
  },
2628
2374
  });
2629
- stream.start().catch((err) => {
2630
- console.error('[canon-host] SSE start error:', err instanceof Error ? err.message : err);
2631
- });
2632
2375
  // ── Control signal watcher ──
2633
2376
  // Session control signals (model, mode, effort). A missing/closed session
2634
2377
  // leaves the node in place; lastSeen still advances, so a stale control is
@@ -2685,94 +2428,54 @@ export async function main() {
2685
2428
  else if (ctrl.effort && !nextEffort) {
2686
2429
  session.setRuntimeControlError('effort', String(ctrl.effort), new Error('Unsupported Claude thinking level.'));
2687
2430
  }
2688
- session.writeState();
2689
- }
2690
- // Interrupt/stop-clear signal. Throwing leaves the node in place
2691
- // (consumeOnError: false); lastSeen has already advanced, so the failed
2692
- // signal is reported but never retried.
2693
- async function handleRuntimeControlSignal(event) {
2694
- const convoId = event.conversationId;
2695
- const session = sessions.get(convoId);
2696
- if (!session || session.closed)
2697
- return { consume: false };
2698
- if (event.type === 'new_session') {
2699
- console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
2700
- await resetRuntimeSession(session);
2701
- return;
2702
- }
2703
- const isActive = session.state.state === 'running';
2704
- if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
2705
- return; // Nothing to interrupt — consume the stale signal
2706
- }
2707
- console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
2708
- if (event.type === 'stop_and_drop') {
2709
- const droppedInputs = session.pendingInputs.splice(0);
2710
- await markSessionPendingInputsRejected(convoId, droppedInputs);
2711
- }
2712
- if (session.activeInput) {
2713
- session.interruptedTurnKeys.add(session.activeInput.turnKey);
2714
- }
2715
- session.pendingFinalText = null;
2716
- session.turnState = 'interrupted';
2717
- session.clearStreaming().catch(() => { });
2718
- typingSignals.clear(convoId).catch(() => { });
2719
- runtimeState.writeTurnState(convoId, {
2720
- turnId: session.currentTurnId,
2721
- state: 'interrupted',
2722
- queueDepth: session.pendingInputs.length,
2723
- currentSpeakerId: agentId,
2724
- lastAcceptedIntent: session.lastAcceptedIntent,
2725
- capabilities: CLAUDE_RUNTIME_CAPABILITIES,
2726
- ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
2727
- completedAt: { '.sv': 'timestamp' },
2728
- }).catch(() => { });
2729
- if (isActive) {
2730
- await session.query.interrupt();
2731
- }
2732
- }
2733
- controlPoller.start();
2734
- // ── Heartbeat (per-session) ──
2735
- const heartbeat = setInterval(() => {
2736
- for (const session of sessions.values()) {
2737
- session.writeState();
2738
- if (session.state.state !== 'running' && session.state.state !== 'requires_action') {
2739
- session.writeTurn();
2431
+ const runtimeControlValues = ctrl.runtimeControlValues ?? {};
2432
+ if ('thinking' in runtimeControlValues) {
2433
+ const nextThinking = parseClaudeThinkingMode(runtimeControlValues.thinking);
2434
+ if (!nextThinking) {
2435
+ session.setRuntimeControlError('thinking', String(runtimeControlValues.thinking), new Error('Unsupported Claude thinking mode.'));
2740
2436
  }
2741
- }
2742
- void publishRuntimeHeartbeat();
2743
- }, HEARTBEAT_MS);
2744
- // ── Idle session cleanup ──
2745
- const idleCheck = setInterval(() => {
2746
- const now = Date.now();
2747
- for (const convoId of [...sessions.keys()]) {
2748
- const session = sessions.get(convoId);
2749
- if (!session || session.state.state === 'running')
2750
- continue;
2751
- if (now - session.lastActivity > IDLE_TIMEOUT_MS) {
2752
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Idle timeout`);
2753
- closeSession(convoId);
2437
+ else if (nextThinking !== session.state.runtimeControlValues?.thinking) {
2438
+ try {
2439
+ await session.query.applyFlagSettings({ alwaysThinkingEnabled: claudeThinkingToFlagValue(nextThinking) });
2440
+ session.state.runtimeControlValues = {
2441
+ ...(session.state.runtimeControlValues ?? {}),
2442
+ thinking: nextThinking,
2443
+ };
2444
+ session.clearRuntimeControlError('thinking');
2445
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Thinking → ${nextThinking}`);
2446
+ }
2447
+ catch (err) {
2448
+ session.setRuntimeControlError('thinking', nextThinking, err);
2449
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set thinking:`, err);
2450
+ }
2754
2451
  }
2755
2452
  }
2756
- }, IDLE_CHECK_MS);
2757
- // ── Graceful shutdown ──
2758
- const shutdown = async () => {
2759
- console.error('[canon-host] Shutting down...');
2760
- controlPoller.stop();
2761
- clearInterval(heartbeat);
2762
- clearInterval(idleCheck);
2763
- stream.stop();
2764
- approvalManager?.dispose();
2765
- await runtimeState.clearAgentRuntime().catch(() => { });
2766
- for (const convoId of [...sessions.keys()]) {
2767
- closeSession(convoId);
2453
+ if ('ultracode' in runtimeControlValues) {
2454
+ const nextUltracode = parseClaudeUltracodeMode(runtimeControlValues.ultracode);
2455
+ if (!nextUltracode) {
2456
+ session.setRuntimeControlError('ultracode', String(runtimeControlValues.ultracode), new Error('Unsupported Claude ultracode mode.'));
2457
+ }
2458
+ else if (nextUltracode !== session.state.runtimeControlValues?.ultracode) {
2459
+ try {
2460
+ await session.query.applyFlagSettings({ ultracode: nextUltracode === 'on' });
2461
+ session.state.runtimeControlValues = {
2462
+ ...(session.state.runtimeControlValues ?? {}),
2463
+ ultracode: nextUltracode,
2464
+ };
2465
+ session.clearRuntimeControlError('ultracode');
2466
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ultracode → ${nextUltracode}`);
2467
+ }
2468
+ catch (err) {
2469
+ session.setRuntimeControlError('ultracode', nextUltracode, err);
2470
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set ultracode:`, err);
2471
+ }
2472
+ }
2768
2473
  }
2769
- markLocalRuntimeStopped(runtimeId);
2770
- (lockHandle ?? getActiveProfileLock())?.release();
2771
- process.exit(0);
2772
- };
2773
- process.on('SIGINT', shutdown);
2774
- process.on('SIGTERM', shutdown);
2775
- process.on('SIGHUP', shutdown);
2474
+ session.writeState();
2475
+ }
2476
+ // ── Heartbeat + idle cleanup + graceful shutdown (agent-host lifecycle) ──
2477
+ lifecycle.startTimers();
2478
+ lifecycle.installSignalHandlers();
2776
2479
  console.error('[canon-host] Ready — sessions created on demand');
2777
2480
  // Keep alive
2778
2481
  await new Promise(() => { });
@@ -2781,7 +2484,7 @@ export async function main() {
2781
2484
  runCli(import.meta.url, main, (err) => {
2782
2485
  const message = err instanceof Error ? err.message : String(err);
2783
2486
  console.error(`[canon-host] ${message}`);
2784
- getActiveProfileLock()?.release();
2487
+ activeLockHandle?.release();
2785
2488
  process.exit(1);
2786
2489
  }, {
2787
2490
  name: 'canon-claude',