@canonmsg/claude-code-plugin 0.19.6 → 0.20.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,12 +26,14 @@ import { execFileSync } from 'node:child_process';
26
26
  import { existsSync } from 'node:fs';
27
27
  import { readFile } from 'node:fs/promises';
28
28
  import { query, } from '@anthropic-ai/claude-agent-sdk';
29
- import { 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';
@@ -73,6 +75,11 @@ Keep this terminal open while you want Canon to reach the agent. Closing it,
73
75
  logging out, rebooting, or sleeping long enough to stop the process takes the
74
76
  local agent offline until you revive it. Docs:
75
77
  https://canonmail.com/agents/integrations`;
78
+ // This host process resolves and locks exactly one agent profile. The lock
79
+ // handle returned by resolveCanonAgent is held here so the top-level runCli
80
+ // error handler (outside main's scope) can release it on a failed start —
81
+ // there is no core module-global profile-lock to fall back on.
82
+ let activeLockHandle = null;
76
83
  let workingDir = process.cwd();
77
84
  let workspaceOptions = [];
78
85
  let workspaceRoots = [];
@@ -300,15 +307,15 @@ function toModelOptions(models) {
300
307
  label: model.displayName,
301
308
  }));
302
309
  }
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'],
310
+ async function publishAgentRuntime(port, runtime) {
311
+ await port.publishHostAgentRuntime({
312
+ clientType: 'claude-code',
313
+ runtime: runtime,
311
314
  });
315
+ }
316
+ async function loadSessionConfig(port, conversationId) {
317
+ const raw = await port.getSessionConfig({ conversationId });
318
+ const config = readHostSessionConfig(raw, ['permissionMode', 'effort']);
312
319
  return {
313
320
  ...config,
314
321
  permissionMode: config?.permissionMode
@@ -423,9 +430,6 @@ async function refreshClaudeRuntimeMetadata(input) {
423
430
  });
424
431
  return runtimeMetadataRefresh;
425
432
  }
426
- function sleep(ms) {
427
- return new Promise((resolve) => setTimeout(resolve, ms));
428
- }
429
433
  function mapClaudeQuestions(value) {
430
434
  if (!Array.isArray(value))
431
435
  return undefined;
@@ -656,9 +660,6 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
656
660
  const STREAMING_THROTTLE_MS = 250;
657
661
  const HEARTBEAT_MS = 30_000;
658
662
  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
663
  const FINAL_DELIVERY_RETRY_MS = 30_000;
663
664
  const MAX_FINAL_DELIVERY_RETRIES = 1;
664
665
  const CLAUDE_RUNTIME_CAPABILITIES = {
@@ -727,7 +728,7 @@ async function buildCanonUserContent(input) {
727
728
  ];
728
729
  }
729
730
  // ── Session factory ─────────────────────────────────────────────────
730
- function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
731
+ function createSession(conversationId, environment, agentId, port, typingSignals, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
731
732
  const { cwd } = environment;
732
733
  const cardResponseUserId = config.ownerId ?? undefined;
733
734
  const cardResponseUser = cardResponseUserId ? { responseUserId: cardResponseUserId } : undefined;
@@ -787,48 +788,15 @@ function createSession(conversationId, environment, agentId, client, typingSigna
787
788
  // eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
788
789
  let session;
789
790
  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
- }
791
+ // ── HITL waiters (generic skeleton from @canonmsg/agent-host) ──
792
+ const hitlWaiters = createHitlWaiters({
793
+ conversationId,
794
+ replyTimeoutMs: REPLY_TIMEOUT_MS,
795
+ }, {
796
+ session: () => session,
797
+ port,
798
+ });
799
+ const { waitForReply, waitForRuntimeInputResponse } = hitlWaiters;
832
800
  function buildArtifactMediaMetadata() {
833
801
  return {
834
802
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
@@ -864,25 +832,27 @@ function createSession(conversationId, environment, agentId, client, typingSigna
864
832
  const questionId = randomUUID();
865
833
  const questions = mapClaudeQuestions(input.questions);
866
834
  const expiresAt = Date.now() + REPLY_TIMEOUT_MS;
867
- await client.createRuntimeInputRequest({
835
+ await port.requestInput({
868
836
  conversationId,
869
837
  inputId: questionId,
870
838
  kind: 'clarify',
871
- expiresAt,
839
+ expiresAt: new Date(expiresAt).toISOString(),
872
840
  title: 'Claude needs input',
873
841
  prompt: questions?.length
874
842
  ? 'Claude needs your input to continue.'
875
843
  : 'Claude needs input to continue.',
876
- ...(questions ? { questions } : {}),
844
+ ...(questions
845
+ ? { questions: questions }
846
+ : { questions: [] }),
877
847
  sensitive: Boolean(questions?.some((question) => question.isSecret)),
878
848
  ...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
879
849
  native: {
880
850
  runtime: 'claude-code',
881
851
  method: 'AskUserQuestion',
882
852
  requestId: questionId,
883
- turnId: session.currentTurnId ?? undefined,
853
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
884
854
  },
885
- turnId: session.currentTurnId ?? undefined,
855
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
886
856
  });
887
857
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
888
858
  const reply = await waitForRuntimeInputResponse({ inputId: questionId, expiresAt });
@@ -937,7 +907,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
937
907
  ...(cardResponseUser ?? {}),
938
908
  ...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
939
909
  });
940
- await sendMessageWithRetry(client, conversationId, planApproval.text, {
910
+ await port.sendMessage({
911
+ conversationId,
912
+ text: planApproval.text,
941
913
  messageId: `claude-plan-${planId}`,
942
914
  ...(session.activeSelfContextId
943
915
  ? { selfContextId: session.activeSelfContextId }
@@ -1090,155 +1062,93 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1090
1062
  setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
1091
1063
  clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
1092
1064
  };
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);
1065
+ // ── Per-session RTDB writers (generic skeleton from @canonmsg/agent-host) ──
1066
+ const writers = createRuntimeWriters({
1067
+ conversationId,
1068
+ agentId,
1069
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1070
+ fallbackTurnId: `claude-${conversationId}`,
1071
+ streamingThrottleMs: STREAMING_THROTTLE_MS,
1072
+ requestedControls: {
1073
+ model: config.model,
1074
+ permissionMode: config.permissionMode,
1075
+ effort: config.effort,
1114
1076
  },
1077
+ }, {
1078
+ session: () => session,
1079
+ port,
1080
+ buildSessionStateExtras: (writerSession) => ({
1081
+ cwd,
1082
+ executionMode: writerSession.environment.mode,
1083
+ ...(writerSession.environment.branch ? { executionBranch: writerSession.environment.branch } : {}),
1084
+ ...(writerSession.environment.worktreePath ? { worktreePath: writerSession.environment.worktreePath } : {}),
1085
+ ...(resolveExecutionFallbackReason(writerSession.environment)
1086
+ ? { executionFallbackReason: resolveExecutionFallbackReason(writerSession.environment) ?? undefined }
1087
+ : {}),
1088
+ }),
1089
+ formatControlError: formatClaudeControlError,
1115
1090
  });
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
- }
1091
+ const { streamingOutput, flushStreaming, onStreamDelta, clearStreaming, getFinalTurnTrail, writeState, writeTurn, setRuntimeControlError, clearRuntimeControlError, refreshVisibleWorkSignal, startVisibleWorkSignal, stopVisibleWorkSignal, } = writers;
1127
1092
  function isActiveTurnInterrupted() {
1128
1093
  const turnKey = session.activeInput?.turnKey;
1129
1094
  return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
1130
1095
  }
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({
1096
+ // ── Turn queue + final delivery (generic skeleton from @canonmsg/agent-host) ──
1097
+ const turnQueue = createTurnQueue({ conversationId }, {
1098
+ session: () => session,
1099
+ isSessionRunning: (queueSession) => queueSession.state.state === 'running',
1100
+ markSessionRunning: (queueSession) => { queueSession.state.state = 'running'; },
1101
+ prepareInput: (input) => prepareArtifactsForInput(input),
1102
+ sendInput: (input) => sendInput(input),
1103
+ interruptRuntime: (queueSession) => queueSession.query.interrupt(),
1104
+ port,
1105
+ writers: {
1106
+ writeTurn,
1107
+ stopVisibleWorkSignal,
1108
+ clearStreaming,
1109
+ },
1110
+ });
1111
+ const { startCanonInput, drainPendingInput, markQueuedMessageAccepted } = turnQueue;
1112
+ const finalDelivery = createFinalDelivery({
1113
+ conversationId,
1114
+ logPrefix: '[canon-host]',
1115
+ delivery: { mode: 'retry', retryMs: FINAL_DELIVERY_RETRY_MS, maxRetries: MAX_FINAL_DELIVERY_RETRIES },
1116
+ }, {
1117
+ session: () => session,
1118
+ port,
1119
+ typingSignals,
1120
+ writers: {
1121
+ writeState,
1122
+ writeTurn,
1123
+ stopVisibleWorkSignal,
1124
+ clearStreaming,
1125
+ getFinalTurnTrail,
1126
+ replaceStreamingSnapshot: (text, status) => streamingOutput.replaceSnapshot(text, status),
1127
+ },
1128
+ resetCompletedTurnState: (deliverySession) => resetClaudeCompletedTurnState(deliverySession),
1129
+ shouldDeliverFinal: (turn, deliverySession) => shouldDeliverClaudeFinal({
1195
1130
  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,
1131
+ finalizedTurnKeys: deliverySession.finalizedTurnKeys,
1132
+ interruptedTurnKeys: deliverySession.interruptedTurnKeys,
1133
+ }),
1134
+ buildFinalMessageId: (turnKey) => buildClaudeFinalMessageId({ agentId, conversationId, turnKey }),
1135
+ drainPendingInput: () => drainPendingInput(),
1136
+ });
1137
+ const { clearIdleResetTimer, clearFinalDeliveryTimer, resetTurnToIdle, scheduleFinalHandoffReset, markPendingFinalDelivery, deliverFinalReply, scheduleFinalDeliveryRetry, } = finalDelivery;
1138
+ async function sendTurnArtifactFile(file) {
1139
+ // Byte transfer is bridge-owned (§B8): hand the bridge a path, it streams
1140
+ // the upload; then send the permanent message with the attachment ref.
1141
+ const uploaded = await port.uploadMedia({
1142
+ path: file.path,
1143
+ mime: inferUploadMimeType(file.path),
1144
+ fileName: file.fileName,
1206
1145
  conversationId,
1207
- turnKey: deliverTurn.turnKey,
1208
1146
  });
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, '', {
1147
+ return port.sendMessage({
1148
+ conversationId,
1149
+ text: '',
1150
+ contentType: uploaded.attachment.kind,
1151
+ attachments: [uploaded.attachment],
1242
1152
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1243
1153
  metadata: buildArtifactMediaMetadata(),
1244
1154
  });
@@ -1275,138 +1185,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1275
1185
  await routeWorkspaceGeneratedArtifacts(input);
1276
1186
  }
1277
1187
  }
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
1188
  async function prepareArtifactsForInput(input) {
1411
1189
  if (input.kind !== 'canon')
1412
1190
  return;
@@ -1422,25 +1200,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1422
1200
  return;
1423
1201
  }
1424
1202
  }
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
1203
  enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
1442
- session.lastActivity = Date.now();
1443
- const input = createClaudeInputEnvelope({
1204
+ turnQueue.enqueueCanonInput(createClaudeInputEnvelope({
1444
1205
  kind: 'canon',
1445
1206
  msg,
1446
1207
  intent,
@@ -1448,32 +1209,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1448
1209
  markAccepted,
1449
1210
  isOwnerTurn,
1450
1211
  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);
1212
+ }));
1477
1213
  };
1478
1214
  writeTurn();
1479
1215
  // ── Process SDK output messages ──
@@ -1752,41 +1488,56 @@ export async function main() {
1752
1488
  for (const warning of workspaceDiscovery.warnings) {
1753
1489
  console.error(`[canon-host] ${warning}`);
1754
1490
  }
1755
- const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
1491
+ const { agentName: profileAgentName, profile, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
1492
+ activeLockHandle = lockHandle ?? null;
1756
1493
  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),
1494
+ // ── Canon via the Bridge (bridge-execution-plan Phase 6) ──
1495
+ // One connectBridge per host process: the daemon owns the SSE stream,
1496
+ // RTDB auth, control polling, dedup, and reconnect. Notification handlers
1497
+ // must exist BEFORE the hello response lands (replayed frames dispatch
1498
+ // synchronously), so a buffered source subscribes inside `configure` and
1499
+ // the real handlers attach after the session machinery is built.
1500
+ const notifications = createBufferedNotificationSource([
1501
+ 'ready',
1502
+ 'connectionState',
1503
+ 'onMessage',
1504
+ 'onMessageDeleted',
1505
+ 'onConversationUpdated',
1506
+ 'onSessionControl',
1507
+ 'onControlSignal',
1508
+ 'onControlReply',
1509
+ ]);
1510
+ const bridge = await connectBridge({
1511
+ ...(profile ? { profile } : {}),
1512
+ mode: 'auto',
1513
+ ...(resolvePackagedBridgeBin() ? { binPath: resolvePackagedBridgeBin() } : {}),
1514
+ hello: {
1515
+ clientType: 'claude-code',
1516
+ wantFamilies: ['messages', 'runtime_turn'],
1517
+ },
1518
+ configure: (bridgeClient) => notifications.connect(bridgeClient),
1519
+ });
1520
+ // The BridgeClient's zod-derived result shapes mirror the core wire types
1521
+ // structurally; the port pins the strong types the host machinery uses.
1522
+ const port = bridge.client;
1523
+ bridge.client.onProtocolError((error) => {
1524
+ console.error(`[canon-host] Bridge protocol error: ${error.message}`);
1764
1525
  });
1526
+ const typingSignals = createPortTypingSignals(port);
1765
1527
  async function markQueuedMessageAcceptedForConversation(conversationId, sourceMessageId, markAccepted) {
1766
1528
  if (!markAccepted || !sourceMessageId)
1767
1529
  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}`);
1530
+ await port.setDisposition({
1531
+ conversationId,
1532
+ messageId: sourceMessageId,
1533
+ inboundDisposition: 'accepted_now',
1534
+ }).catch(() => { });
1789
1535
  }
1536
+ const agentId = bridge.hello.agentId;
1537
+ const ownerId = bridge.hello.agentContext.ownerId ?? null;
1538
+ const ownerName = bridge.hello.agentContext.ownerName ?? null;
1539
+ console.error(`[canon-host] Connected as ${bridge.hello.agentName || agentId} `
1540
+ + `(canon-bridge${bridge.spawnedDaemon ? ' spawned' : ''} at ${bridge.socketPath})`);
1790
1541
  const launchArgs = [...process.argv.slice(2)];
1791
1542
  if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
1792
1543
  launchArgs.push('--cwd', workingDir);
@@ -1815,11 +1566,6 @@ export async function main() {
1815
1566
  lastStartedAt: new Date().toISOString(),
1816
1567
  lastHeartbeatAt: new Date().toISOString(),
1817
1568
  });
1818
- const runtimeState = createRuntimeStatePublisher({
1819
- agentId,
1820
- clientType: 'claude-code',
1821
- hostMode: true,
1822
- });
1823
1569
  let streamConnected = false;
1824
1570
  const hostAvailableExecutionModes = [
1825
1571
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1844,11 +1590,14 @@ export async function main() {
1844
1590
  presentation: runtimePresentation,
1845
1591
  }),
1846
1592
  };
1847
- const conversationCache = new Map();
1848
- const knownConversationIds = new Set();
1849
- const promptedGroupContextConversationIds = new Set();
1850
- const pendingMembershipChanges = new Map();
1851
- let lastKnownConversationRefreshAt = 0;
1593
+ // ── Conversation directory (shared inbound-ingestion bookkeeping from @canonmsg/agent-host) ──
1594
+ const directory = createConversationDirectory({
1595
+ agentId,
1596
+ ownerId,
1597
+ ownerName,
1598
+ refreshFloorMs: HEARTBEAT_MS,
1599
+ }, { port });
1600
+ const { conversationCache, knownConversationIds, getConversationMeta, refreshKnownConversationIds, handleConversationUpdated, markGroupContextModeUsed, loadHydratedInboundContext, } = directory;
1852
1601
  const publishRuntimeHeartbeat = async () => {
1853
1602
  heartbeatLocalRuntimeEntry(runtimeId, {
1854
1603
  agentId,
@@ -1869,18 +1618,17 @@ export async function main() {
1869
1618
  console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
1870
1619
  return runtimeMetadata;
1871
1620
  });
1872
- await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
1621
+ await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
1873
1622
  console.error('[canon-host] Failed to publish agent runtime:', error);
1874
1623
  });
1875
- await publishHostSessionSnapshots({
1624
+ await port.publishHostSessionSnapshots({
1876
1625
  conversationIds: Array.from(knownConversationIds),
1877
- agentId,
1878
1626
  clientType: 'claude-code',
1879
1627
  runtime: runtimeDescriptor,
1880
- workspaceOptions,
1628
+ workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
1881
1629
  defaultCwd: workingDir,
1882
1630
  extraSessionConfigFields: ['permissionMode', 'effort'],
1883
- liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
1631
+ liveSessionConfigByConversation: Object.fromEntries(Array.from(sessions.values()).map((session) => {
1884
1632
  const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
1885
1633
  return [
1886
1634
  session.conversationId,
@@ -1933,7 +1681,10 @@ export async function main() {
1933
1681
  worktreePath: session?.environment.worktreePath ?? null,
1934
1682
  fallbackReason: resolveExecutionFallbackReason(session?.environment),
1935
1683
  });
1936
- await runtimeState.writeRuntimeInfo(conversationId, payload);
1684
+ await port.publishRuntimeStatus({
1685
+ conversationId,
1686
+ presentation: payload,
1687
+ });
1937
1688
  })).catch((error) => {
1938
1689
  console.error('[canon-host] Failed to publish runtime info:', error);
1939
1690
  });
@@ -1975,14 +1726,12 @@ export async function main() {
1975
1726
  };
1976
1727
  }
1977
1728
  try {
1978
- const convos = await client.getConversations();
1979
- lastKnownConversationRefreshAt = Date.now();
1729
+ const convos = await directory.listConversations();
1730
+ directory.primeFromStartup(convos);
1980
1731
  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(() => { });
1732
+ port.clearStreaming({ conversationId: conversation.id }).catch(() => { });
1733
+ port.clearSessionState({ conversationId: conversation.id }).catch(() => { });
1734
+ port.clearTurnState({ conversationId: conversation.id }).catch(() => { });
1986
1735
  typingSignals.clear(conversation.id).catch(() => { });
1987
1736
  }
1988
1737
  console.error(`[canon-host] ${convos.length} conversations`);
@@ -1990,64 +1739,68 @@ export async function main() {
1990
1739
  catch {
1991
1740
  console.error('[canon-host] Could not load conversations');
1992
1741
  }
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 },
1742
+ // ── Session manager (generic lifecycle from @canonmsg/agent-host) ──
1743
+ const approvalManager = ownerId
1744
+ ? new ApprovalManager(createPortApprovalClient(port), agentId, ownerId)
1745
+ : null;
1746
+ const lifecycle = createSessionLifecycle({
1747
+ maxSessions: MAX_SESSIONS,
1748
+ idleTimeoutMs: IDLE_TIMEOUT_MS,
1749
+ heartbeatMs: HEARTBEAT_MS,
1750
+ idleCheckMs: IDLE_CHECK_MS,
1751
+ logPrefix: '[canon-host]',
1752
+ }, {
1753
+ knownConversationIds,
1754
+ createSession: (conversationId) => createSessionForConversation(conversationId),
1755
+ isSessionRunning: (session) => session.state.state === 'running',
1756
+ writeTurnOnHeartbeat: (session) => session.state.state !== 'running' && session.state.state !== 'requires_action',
1757
+ writeSessionState: (session) => session.writeState(),
1758
+ writeSessionTurn: (session) => session.writeTurn(),
1759
+ releaseEnvironment: (session) => releaseConversationEnvironment(session.environment),
1760
+ persistResumeState,
1761
+ disposeRuntime: (session) => session.query.close(),
1762
+ interruptRuntime: (session) => session.query.interrupt(),
1763
+ port,
1764
+ publishRuntimeHeartbeat,
1765
+ onShutdown: async () => {
1766
+ detachControlNotifications();
1767
+ notifications.detach();
1768
+ approvalManager?.dispose();
1769
+ await port.clearAgentRuntime().catch(() => { });
1770
+ bridge.client.close();
2022
1771
  },
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);
1772
+ onShutdownComplete: () => {
1773
+ markLocalRuntimeStopped(runtimeId);
1774
+ lockHandle?.release();
2028
1775
  },
2029
1776
  });
1777
+ const { sessions, getOrCreateSession, closeSession, resetRuntimeSession, markSessionPendingInputsRejected, removeQueuedInput, } = lifecycle;
1778
+ // Interrupt/stop-clear signal handling (generic shell from @canonmsg/agent-host).
1779
+ const handleRuntimeControlSignal = createRuntimeSignalHandler({
1780
+ agentId,
1781
+ logPrefix: '[canon-host]',
1782
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1783
+ }, {
1784
+ sessions,
1785
+ port,
1786
+ isSessionRunning: (session) => session.state.state === 'running',
1787
+ interruptRuntime: (session) => session.query.interrupt(),
1788
+ resetSession: (session) => resetRuntimeSession(session),
1789
+ markSessionPendingInputsRejected,
1790
+ });
1791
+ // Control events arrive as bridge notifications — the bridge owns the ONE
1792
+ // authoritative /control consumer for this identity (plan §2); the host's
1793
+ // adaptive poller (active/idle cadence, jitter, per-session baselines) is
1794
+ // deleted with it.
1795
+ const detachControlNotifications = attachHostControlNotifications(notifications.source, { logPrefix: '[canon-host]' }, {
1796
+ onSessionControl: applyRuntimeSessionControl,
1797
+ onControlSignal: handleRuntimeControlSignal,
1798
+ });
2030
1799
  /** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
2031
1800
  const savedSessionIds = new Map();
2032
- const { getConversationMeta } = createConversationMetadataLoader({
2033
- client,
2034
- conversationCache,
2035
- });
2036
1801
  function resolveWorkspaceIdForBaseCwd(baseCwd) {
2037
1802
  return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
2038
1803
  }
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
1804
  async function canRequestCanonApproval(conversationId) {
2052
1805
  if (!ownerId)
2053
1806
  return false;
@@ -2055,80 +1808,9 @@ export async function main() {
2055
1808
  .catch(() => conversationCache.get(conversationId) ?? null);
2056
1809
  return Array.isArray(conversation?.memberIds) && conversation.memberIds.includes(ownerId);
2057
1810
  }
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);
1811
+ /** Resume-state persistence on session close (claude-specific: SDK sessionId). */
1812
+ function persistResumeState(session, saveResume) {
1813
+ const conversationId = session.conversationId;
2132
1814
  const resumeKey = buildConversationEnvironmentKey(conversationId, session.environment.baseCwd);
2133
1815
  if (saveResume && session.sdkSessionId) {
2134
1816
  savedSessionIds.set(resumeKey, session.sdkSessionId);
@@ -2160,89 +1842,11 @@ export async function main() {
2160
1842
  });
2161
1843
  }
2162
1844
  }
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
- }
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
1845
  }
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);
1846
+ /** Runtime-specific session construction, invoked via the lifecycle's getOrCreateSession. */
1847
+ function createSessionForConversation(conversationId) {
1848
+ return (async () => {
1849
+ const config = await loadSessionConfig(port, conversationId);
2246
1850
  const sessionExecutionMode = resolveSessionExecutionMode(config);
2247
1851
  const workspaceCwd = resolveWorkspaceCwd(config);
2248
1852
  const environment = prepareConversationEnvironment({
@@ -2259,7 +1863,7 @@ export async function main() {
2259
1863
  executionMode: environment.mode,
2260
1864
  });
2261
1865
  const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
2262
- const session = createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, (convoId, sdkSessionId) => {
1866
+ const session = createSession(conversationId, environment, agentId, port, typingSignals, (convoId, sdkSessionId) => {
2263
1867
  // Called when session processing loop ends — save session ID for resume
2264
1868
  const existing = sessions.get(convoId);
2265
1869
  if (existing) {
@@ -2283,10 +1887,9 @@ export async function main() {
2283
1887
  }
2284
1888
  existing?.clearStreaming().catch(() => { });
2285
1889
  typingSignals.clear(convoId).catch(() => { });
2286
- typingSignals.dispose(convoId);
2287
1890
  sessions.delete(convoId);
2288
- runtimeState.clearSessionState(convoId).catch(() => { });
2289
- runtimeState.clearTurnState(convoId).catch(() => { });
1891
+ port.clearSessionState({ conversationId: convoId }).catch(() => { });
1892
+ port.clearTurnState({ conversationId: convoId }).catch(() => { });
2290
1893
  }, (descriptor) => {
2291
1894
  runtimeDescriptor = descriptor;
2292
1895
  void publishRuntimeHeartbeat();
@@ -2303,7 +1906,6 @@ export async function main() {
2303
1906
  ownerId,
2304
1907
  }, resumeId);
2305
1908
  sessions.set(conversationId, session);
2306
- await controlPoller.baseline([conversationId]);
2307
1909
  return session;
2308
1910
  }
2309
1911
  catch (error) {
@@ -2311,13 +1913,6 @@ export async function main() {
2311
1913
  throw error;
2312
1914
  }
2313
1915
  })();
2314
- pendingSessionCreations.set(conversationId, creation);
2315
- try {
2316
- return await creation;
2317
- }
2318
- finally {
2319
- pendingSessionCreations.delete(conversationId);
2320
- }
2321
1916
  }
2322
1917
  async function enqueueRecoveredInboundMessage(input) {
2323
1918
  const m = input.message;
@@ -2374,7 +1969,9 @@ export async function main() {
2374
1969
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
2375
1970
  console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
2376
1971
  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}`, {
1972
+ await port.sendMessage({
1973
+ conversationId: input.conversationId,
1974
+ text: `I couldn't start a coding session for this workspace: ${userMessage}`,
2378
1975
  messageId: `claude-start-failed-${m.id}`,
2379
1976
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2380
1977
  metadata: {
@@ -2424,7 +2021,12 @@ export async function main() {
2424
2021
  baseCwd: workingDir,
2425
2022
  })?.lastInboundMessageId;
2426
2023
  const recovery = await collectMissedInboundMessages({
2427
- fetchPage: (before) => client.getMessagesPage(conversation.id, STARTUP_RECOVERY_PAGE_SIZE, before),
2024
+ fetchPage: (before) => port.getMessages({
2025
+ conversationId: conversation.id,
2026
+ limit: STARTUP_RECOVERY_PAGE_SIZE,
2027
+ ...(before ? { before } : {}),
2028
+ includeBehavior: true,
2029
+ }),
2428
2030
  cursor,
2429
2031
  agentId,
2430
2032
  });
@@ -2447,188 +2049,208 @@ export async function main() {
2447
2049
  catch (error) {
2448
2050
  console.error('[canon-host] Failed startup recovery:', error instanceof Error ? error.message : error);
2449
2051
  }
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
- }
2052
+ // ── Inbound Canon events (bridge notifications) ──
2053
+ // The bridge owns SSE reconnect/heartbeat/replay + dedup + self-filtering;
2054
+ // HITL control-reply messages (approval_reply / plan_approval_reply) are
2055
+ // STRIPPED from onMessage bridge-side — unmatched ones arrive as
2056
+ // onControlReply and feed the plan-approval waiters below.
2057
+ const handleInboundMessage = (payload) => {
2058
+ const m = payload.message;
2059
+ if (m.senderId === agentId)
2060
+ return;
2061
+ const convoId = payload.conversationId;
2062
+ knownConversationIds.add(convoId);
2063
+ const sender = m.senderName || m.senderId;
2064
+ const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2065
+ if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2066
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2067
+ return;
2068
+ }
2069
+ void (async () => {
2070
+ let materialized = [];
2071
+ if (m.id) {
2072
+ try {
2073
+ materialized = await materializeMessageMedia({
2074
+ id: m.id,
2075
+ attachments: m.attachments ?? [],
2076
+ }, { agentId, conversationId: convoId });
2474
2077
  }
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
- }
2078
+ catch (error) {
2079
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
2501
2080
  }
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;
2081
+ }
2082
+ const content = renderInboundContent(m, materialized);
2083
+ const hydrated = await loadHydratedInboundContext({
2084
+ conversationId: convoId,
2085
+ message: m,
2086
+ senderName: sender,
2087
+ isOwner,
2088
+ activeSelfContextId: payload.activeSelfContextId,
2089
+ selfContexts: payload.selfContexts,
2090
+ provenance: payload.provenance,
2091
+ });
2092
+ const behavior = payload.behavior ?? hydrated.behavior;
2093
+ const activeSelfContextId = hydrated.activeSelfContextId;
2094
+ const selfContexts = hydrated.selfContexts;
2095
+ const replyMedia = await materializePromptReplyContext({
2096
+ replyContext: hydrated.replyContext,
2097
+ agentId,
2098
+ conversationId: convoId,
2099
+ logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
2100
+ });
2101
+ const replyContext = replyMedia.replyContext;
2102
+ const participantContext = hydrated.participantContext;
2103
+ const autoReply = payload.turnDispatch?.kind === 'run_turn'
2104
+ ? {
2105
+ allow: true,
2106
+ reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
2507
2107
  }
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: () => {
2108
+ : decideAutoReply(participantContext, behavior);
2109
+ if (!autoReply.allow) {
2110
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
2111
+ return;
2112
+ }
2113
+ markGroupContextModeUsed(convoId, participantContext.groupContextMode);
2114
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
2115
+ const turnMetadata = normalizeTurnMetadata(m.metadata);
2116
+ const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
2117
+ const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
2118
+ let session;
2119
+ try {
2120
+ session = await getOrCreateSession(convoId);
2121
+ }
2122
+ catch (error) {
2123
+ const message = error instanceof Error ? error.message : String(error);
2124
+ const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
2125
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
2126
+ await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
2127
+ await port.sendMessage({
2128
+ conversationId: convoId,
2129
+ text: `I couldn't start a coding session for this workspace: ${userMessage}`,
2130
+ messageId: `claude-start-failed-${m.id}`,
2131
+ ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2132
+ metadata: {
2133
+ turnSemantics: 'turn_complete',
2134
+ replyBehavior: 'suppress_auto_reply',
2135
+ },
2136
+ }).catch(() => { });
2137
+ return;
2138
+ }
2139
+ session.activeSelfContextId = activeSelfContextId;
2140
+ const promptText = buildCanonPrompt({
2141
+ content,
2142
+ conversationId: convoId,
2143
+ participantContext,
2144
+ behavior,
2145
+ selfContexts,
2146
+ activeSelfContextId,
2147
+ provenance: hydrated.provenance,
2148
+ replyContext,
2149
+ message: m,
2150
+ });
2151
+ const messageContent = await buildCanonUserContent({
2152
+ promptText,
2153
+ materialized: [...replyMedia.materialized, ...materialized],
2154
+ });
2155
+ const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
2156
+ session.enqueueInbound({
2157
+ type: 'user',
2158
+ message: {
2159
+ role: 'user',
2160
+ content: messageContent,
2161
+ },
2162
+ parent_tool_use_id: null,
2163
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
2164
+ if (m.id) {
2165
+ saveRuntimeSessionState(runtimeId, {
2166
+ conversationId: convoId,
2167
+ baseCwd: workingDir,
2168
+ lastInboundMessageId: m.id,
2169
+ });
2170
+ }
2171
+ })();
2172
+ };
2173
+ /**
2174
+ * Stripped HITL control replies that matched no bridge record: the plan
2175
+ * approval flow (host-raised via a plain sendMessage) resolves its
2176
+ * metadata-reply waiter here; anything else is fed to the approval
2177
+ * manager as a late/second-chance consume.
2178
+ */
2179
+ const handleControlReply = (convoId, m) => {
2180
+ if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
2181
+ const consumed = approvalManager?.handleMessage(convoId, {
2182
+ senderId: m.senderId,
2183
+ metadata: m.metadata,
2184
+ }) ?? false;
2185
+ if (m.metadata?.type === 'approval_reply') {
2186
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
2187
+ return;
2188
+ }
2189
+ if (consumed) {
2190
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
2191
+ return;
2192
+ }
2193
+ }
2194
+ if (m.metadata?.type === 'plan_approval_reply') {
2195
+ const existingSession = sessions.get(convoId);
2196
+ const pendingReply = existingSession?.pendingReply;
2197
+ const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2198
+ const replyRequestId = m.metadata.planId;
2199
+ if (!pendingReply) {
2200
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
2201
+ return;
2202
+ }
2203
+ if (!isOwnerReply) {
2204
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
2205
+ return;
2206
+ }
2207
+ if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
2208
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
2209
+ return;
2210
+ }
2211
+ if (existingSession?.pendingReply) {
2212
+ const { resolve } = existingSession.pendingReply;
2213
+ existingSession.pendingReply = null;
2214
+ existingSession.lastActivity = Date.now();
2215
+ resolve(m.metadata);
2216
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
2217
+ }
2218
+ }
2219
+ };
2220
+ notifications.attach({
2221
+ onMessage: (params) => {
2222
+ handleInboundMessage(params);
2223
+ },
2224
+ onMessageDeleted: (params) => {
2225
+ const payload = params;
2226
+ removeQueuedInput(payload.conversationId, payload.messageId);
2227
+ },
2228
+ onConversationUpdated: (params) => {
2229
+ handleConversationUpdated(params);
2230
+ },
2231
+ onControlReply: (params) => {
2232
+ const payload = params;
2233
+ handleControlReply(payload.conversationId, payload.message);
2234
+ },
2235
+ ready: () => {
2236
+ streamConnected = true;
2237
+ void publishRuntimeHeartbeat();
2238
+ console.error('[canon-host] Bridge ready');
2239
+ },
2240
+ connectionState: (params) => {
2241
+ const { upstream } = params;
2242
+ if (upstream === 'connected') {
2617
2243
  streamConnected = true;
2618
2244
  void publishRuntimeHeartbeat();
2619
- console.error('[canon-host] SSE connected');
2620
- },
2621
- onDisconnected: () => {
2245
+ console.error('[canon-host] Canon upstream connected');
2246
+ }
2247
+ else {
2622
2248
  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}`),
2249
+ port.clearAgentRuntime().catch(() => { });
2250
+ console.error(`[canon-host] Canon upstream ${upstream}`);
2251
+ }
2627
2252
  },
2628
2253
  });
2629
- stream.start().catch((err) => {
2630
- console.error('[canon-host] SSE start error:', err instanceof Error ? err.message : err);
2631
- });
2632
2254
  // ── Control signal watcher ──
2633
2255
  // Session control signals (model, mode, effort). A missing/closed session
2634
2256
  // leaves the node in place; lastSeen still advances, so a stale control is
@@ -2687,92 +2309,9 @@ export async function main() {
2687
2309
  }
2688
2310
  session.writeState();
2689
2311
  }
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();
2740
- }
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);
2754
- }
2755
- }
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);
2768
- }
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);
2312
+ // ── Heartbeat + idle cleanup + graceful shutdown (agent-host lifecycle) ──
2313
+ lifecycle.startTimers();
2314
+ lifecycle.installSignalHandlers();
2776
2315
  console.error('[canon-host] Ready — sessions created on demand');
2777
2316
  // Keep alive
2778
2317
  await new Promise(() => { });
@@ -2781,7 +2320,7 @@ export async function main() {
2781
2320
  runCli(import.meta.url, main, (err) => {
2782
2321
  const message = err instanceof Error ? err.message : String(err);
2783
2322
  console.error(`[canon-host] ${message}`);
2784
- getActiveProfileLock()?.release();
2323
+ activeLockHandle?.release();
2785
2324
  process.exit(1);
2786
2325
  }, {
2787
2326
  name: 'canon-claude',