@canonmsg/claude-code-plugin 0.25.0 → 0.25.2

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
@@ -28,12 +28,14 @@ import { readFile } from 'node:fs/promises';
28
28
  import { query, } from '@anthropic-ai/claude-agent-sdk';
29
29
  import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
30
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, 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, RuntimeRequestManager, 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';
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, 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, RuntimeRequestManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, readLocalRuntimeEntry, resolveCanonAgent, initRTDBAuth, sendMessageWithRetry, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
32
32
  import { decideAutoReply, } from './inbound-policy.js';
33
33
  import { runCli } from './cli-entry.js';
34
34
  import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
35
35
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
36
- import { applyClaudeSessionControl, buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
36
+ import { applyClaudeSessionControl, buildClaudeFinalMessageId, claudeModelInfoToOption, confirmClaudeInterrupt, createClaudeInputEnvelope, formatClaudeControlError, isClaudeCustomModelOption, mergeClaudeDiscoveredModelOptions, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
37
+ import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, } from './user-dialog.js';
38
+ import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
37
39
  function parseRuntimeVisibilityPreset(value) {
38
40
  return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
39
41
  }
@@ -309,6 +311,7 @@ function resolveClaudeCliPath() {
309
311
  const override = process.env.CANON_CLAUDE_CLI_PATH?.trim();
310
312
  if (override) {
311
313
  if (existsSync(override)) {
314
+ assertExternalClaudeCliVersion(override);
312
315
  cachedClaudeCliPath = override;
313
316
  console.error(`[canon-host] claude CLI override: ${override}`);
314
317
  return override;
@@ -320,6 +323,7 @@ function resolveClaudeCliPath() {
320
323
  encoding: 'utf8',
321
324
  }).trim();
322
325
  if (resolved && existsSync(resolved)) {
326
+ assertExternalClaudeCliVersion(resolved);
323
327
  cachedClaudeCliPath = resolved;
324
328
  console.error(`[canon-host] claude CLI on PATH: ${resolved}`);
325
329
  return resolved;
@@ -332,11 +336,25 @@ function resolveClaudeCliPath() {
332
336
  console.error('[canon-host] claude CLI not on PATH; using SDK-bundled binary');
333
337
  return undefined;
334
338
  }
339
+ function assertExternalClaudeCliVersion(cliPath) {
340
+ const output = execFileSync(cliPath, ['--version'], { encoding: 'utf8' }).trim();
341
+ const match = output.match(/^(\d+)\.(\d+)\.(\d+)/);
342
+ if (!match) {
343
+ throw new Error(`Could not determine Claude Code version from: ${output}`);
344
+ }
345
+ const installed = match.slice(1, 4).map(Number);
346
+ const minimum = [2, 1, 201];
347
+ const supported = installed.some((value, index) => {
348
+ if (value === minimum[index])
349
+ return false;
350
+ return value > minimum[index] && installed.slice(0, index).every((part, partIndex) => part === minimum[partIndex]);
351
+ }) || installed.every((value, index) => value === minimum[index]);
352
+ if (!supported) {
353
+ throw new Error(`Claude Code ${minimum.join('.')} or newer is required; found ${match[0]}.`);
354
+ }
355
+ }
335
356
  function toModelOptions(models) {
336
- return models.map((model) => ({
337
- value: model.value,
338
- label: model.displayName,
339
- }));
357
+ return models.map(claudeModelInfoToOption);
340
358
  }
341
359
  async function publishAgentRuntime(agentId, runtime) {
342
360
  await publishHostAgentRuntime(agentId, 'claude-code', runtime);
@@ -376,7 +394,28 @@ function resolveExecutionFallbackReason(environment) {
376
394
  ? null
377
395
  : environment.reason;
378
396
  }
397
+ const CLAUDE_SUPPLEMENTAL_MODEL_PROBES = ['opus', 'fable'];
379
398
  async function detectRuntimeModels(cwd) {
399
+ const [discovered, supplemental] = await Promise.all([
400
+ detectRuntimeModelsForSelection(cwd, 'default'),
401
+ detectSupplementalRuntimeModels(cwd),
402
+ ]);
403
+ return mergeClaudeDiscoveredModelOptions(discovered, ...supplemental);
404
+ }
405
+ async function detectSupplementalRuntimeModels(cwd) {
406
+ return Promise.all(CLAUDE_SUPPLEMENTAL_MODEL_PROBES.map(async (model) => {
407
+ try {
408
+ const options = await detectRuntimeModelsForSelection(cwd, model);
409
+ const option = options.find((entry) => entry.value === model);
410
+ return option && !isClaudeCustomModelOption(option) ? [option] : [];
411
+ }
412
+ catch (error) {
413
+ console.error(`[canon-host] Supplemental Claude model probe failed for ${model}:`, error);
414
+ return [];
415
+ }
416
+ }));
417
+ }
418
+ async function detectRuntimeModelsForSelection(cwd, model) {
380
419
  const emptyPrompt = {
381
420
  async *[Symbol.asyncIterator]() { },
382
421
  };
@@ -386,6 +425,7 @@ async function detectRuntimeModels(cwd) {
386
425
  options: {
387
426
  systemPrompt: { type: 'preset', preset: 'claude_code' },
388
427
  tools: { type: 'preset', preset: 'claude_code' },
428
+ model,
389
429
  permissionMode: 'default',
390
430
  cwd,
391
431
  settings: { forceLoginMethod: 'claudeai' },
@@ -413,6 +453,7 @@ async function inspectClaudeRuntimeMetadata(cwd, activeQuery) {
413
453
  options: {
414
454
  systemPrompt: { type: 'preset', preset: 'claude_code' },
415
455
  tools: { type: 'preset', preset: 'claude_code' },
456
+ model: 'default',
416
457
  permissionMode: 'default',
417
458
  cwd,
418
459
  settings: { forceLoginMethod: 'claudeai' },
@@ -465,78 +506,6 @@ async function refreshClaudeRuntimeMetadata(input) {
465
506
  function sleep(ms) {
466
507
  return new Promise((resolve) => setTimeout(resolve, ms));
467
508
  }
468
- function mapClaudeQuestions(value) {
469
- if (!Array.isArray(value))
470
- return undefined;
471
- const questions = value.slice(0, 12).flatMap((entry) => {
472
- if (!entry || typeof entry !== 'object' || Array.isArray(entry))
473
- return [];
474
- const record = entry;
475
- const id = typeof record.id === 'string' && record.id.trim()
476
- ? record.id.trim().slice(0, 120)
477
- : typeof record.question === 'string' && record.question.trim()
478
- ? record.question.trim().slice(0, 80).replace(/[^A-Za-z0-9_.:-]+/g, '-')
479
- : null;
480
- const question = typeof record.question === 'string' && record.question.trim()
481
- ? record.question.trim().slice(0, 1000)
482
- : null;
483
- if (!id || !/^[A-Za-z0-9_.:-]{1,120}$/.test(id) || !question)
484
- return [];
485
- const header = typeof record.header === 'string' && record.header.trim()
486
- ? record.header.trim().slice(0, 120)
487
- : undefined;
488
- const rawOptions = Array.isArray(record.options) ? record.options : [];
489
- const choices = rawOptions.slice(0, 12).flatMap((option) => {
490
- if (!option || typeof option !== 'object' || Array.isArray(option))
491
- return [];
492
- const optionRecord = option;
493
- const label = typeof optionRecord.label === 'string' && optionRecord.label.trim()
494
- ? optionRecord.label.trim().slice(0, 120)
495
- : typeof optionRecord.value === 'string' && optionRecord.value.trim()
496
- ? optionRecord.value.trim().slice(0, 120)
497
- : null;
498
- if (!label)
499
- return [];
500
- const optionValue = typeof optionRecord.value === 'string' && optionRecord.value.trim()
501
- ? optionRecord.value.trim().slice(0, 120)
502
- : label;
503
- const description = typeof optionRecord.description === 'string' && optionRecord.description.trim()
504
- ? optionRecord.description.trim().slice(0, 240)
505
- : undefined;
506
- const preview = typeof optionRecord.preview === 'string' && optionRecord.preview.trim()
507
- ? optionRecord.preview.trim().slice(0, 4000)
508
- : undefined;
509
- return [{
510
- label,
511
- value: optionValue,
512
- ...(description ? { description } : {}),
513
- ...(preview ? { preview } : {}),
514
- }];
515
- });
516
- return [{
517
- id,
518
- question,
519
- ...(header ? { header } : {}),
520
- ...(choices.length ? { choices } : {}),
521
- // Mac-app parity: AskUserQuestion always offers a free-text "Other" escape
522
- // hatch (the SDK adds it client-side, not via the tool schema). Default it
523
- // on for choice questions unless the agent explicitly opted out.
524
- ...(resolveQuestionAllowOther(choices.length > 0, record.allowOther ?? record.isOther)
525
- ? { allowOther: true }
526
- : {}),
527
- ...(record.isSecret === true ? { isSecret: true } : {}),
528
- ...(record.multiSelect === true ? { multiSelect: true } : {}),
529
- }];
530
- });
531
- return questions.length ? questions : undefined;
532
- }
533
- function flattenRuntimeInputAnswers(answers) {
534
- const flattened = {};
535
- for (const [questionId, answer] of Object.entries(answers ?? {})) {
536
- flattened[questionId] = answer.answers.join(', ');
537
- }
538
- return flattened;
539
- }
540
509
  /** Pre-image reader for approval diff synthesis: missing file → null (Write creates it). */
541
510
  async function readApprovalDiffPreImage(filePath) {
542
511
  try {
@@ -700,6 +669,7 @@ const IDLE_CONTROL_POLL_MS = 10_000;
700
669
  const CONTROL_POLL_JITTER_MS = 1_000;
701
670
  const FINAL_DELIVERY_RETRY_MS = 30_000;
702
671
  const MAX_FINAL_DELIVERY_RETRIES = 1;
672
+ const CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID = 'claude-recovery-cursor-v1';
703
673
  const CLAUDE_RUNTIME_CAPABILITIES = {
704
674
  ...DEFAULT_RUNTIME_CAPABILITIES,
705
675
  supportsInterrupt: true,
@@ -784,7 +754,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
784
754
  messageQueue.push(input);
785
755
  }
786
756
  };
787
- let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
757
+ let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => {
788
758
  sendInput(createClaudeInputEnvelope({
789
759
  kind: 'canon',
790
760
  msg,
@@ -792,6 +762,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
792
762
  sourceMessageId,
793
763
  markAccepted,
794
764
  isOwnerTurn,
765
+ requestingUserId,
795
766
  artifactRoutingMode,
796
767
  }));
797
768
  };
@@ -825,6 +796,73 @@ function createSession(conversationId, environment, agentId, client, typingSigna
825
796
  // eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
826
797
  let session;
827
798
  const REPLY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
799
+ const activeUserDialogs = new Map();
800
+ const completedUserDialogs = new Map();
801
+ async function requestClaudeUserDialog(dialog, nativeRequestId, signal) {
802
+ const questionId = /^[A-Za-z0-9_.:-]{1,160}$/.test(nativeRequestId)
803
+ ? nativeRequestId
804
+ : randomUUID();
805
+ const completed = completedUserDialogs.get(questionId);
806
+ if (completed)
807
+ return completed;
808
+ const existing = activeUserDialogs.get(questionId);
809
+ if (existing)
810
+ return existing;
811
+ const responseUserId = session.activeInput?.requestingUserId ?? undefined;
812
+ if (!responseUserId) {
813
+ return buildClaudeAskUserPermissionDenied('No eligible human participant can answer this question.');
814
+ }
815
+ const run = (async () => {
816
+ try {
817
+ const reply = await config.runtimeInputManager.request('input', conversationId, {
818
+ kind: 'clarify',
819
+ title: 'Claude needs input',
820
+ prompt: 'Claude needs your input to continue.',
821
+ questions: dialog.questions,
822
+ sensitive: dialog.questions.some((question) => question.isSecret),
823
+ responseUserId,
824
+ native: {
825
+ runtime: 'claude-code',
826
+ method: 'AskUserQuestion',
827
+ requestId: nativeRequestId,
828
+ turnId: session.currentTurnId ?? undefined,
829
+ },
830
+ turnId: session.currentTurnId ?? undefined,
831
+ }, {
832
+ requestId: questionId,
833
+ expiresAt: Date.now() + REPLY_TIMEOUT_MS,
834
+ responderPolicy: 'infer',
835
+ signal,
836
+ onCreated: () => {
837
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
838
+ session.lastActivity = Date.now();
839
+ },
840
+ });
841
+ if (reply.status === 'submitted') {
842
+ return buildClaudeAskUserPermissionResult(dialog, reply);
843
+ }
844
+ return buildClaudeAskUserPermissionDenied(reply.status === 'timeout' ? 'No reply received (timed out)' : 'The user cancelled this question.');
845
+ }
846
+ catch {
847
+ return buildClaudeAskUserPermissionDenied(signal.aborted ? 'The question was interrupted.' : 'No reply received (timed out)');
848
+ }
849
+ })();
850
+ activeUserDialogs.set(questionId, run);
851
+ try {
852
+ const result = await run;
853
+ completedUserDialogs.set(questionId, result);
854
+ while (completedUserDialogs.size > 64) {
855
+ const oldest = completedUserDialogs.keys().next().value;
856
+ if (oldest === undefined)
857
+ break;
858
+ completedUserDialogs.delete(oldest);
859
+ }
860
+ return result;
861
+ }
862
+ finally {
863
+ activeUserDialogs.delete(questionId);
864
+ }
865
+ }
828
866
  function waitForReply(type, requestId) {
829
867
  // Keep session alive while waiting for user input
830
868
  session.lastActivity = Date.now();
@@ -857,6 +895,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
857
895
  const queryOptions = {
858
896
  systemPrompt: { type: 'preset', preset: 'claude_code' },
859
897
  tools: { type: 'preset', preset: 'claude_code' },
898
+ model: 'default',
860
899
  permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
861
900
  allowDangerouslySkipPermissions: true,
862
901
  cwd,
@@ -872,59 +911,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
872
911
  console.error(`[claude-cli] [${conversationId.slice(0, 8)}] ${trimmed}`);
873
912
  }
874
913
  },
875
- canUseTool: async (toolName, input) => {
914
+ supportedDialogKinds: [...CLAUDE_SUPPORTED_DIALOG_KINDS],
915
+ onUserDialog: async (request, { signal }) => {
916
+ const dialog = parseClaudeAskUserDialog(request);
917
+ if (!dialog)
918
+ return { behavior: 'cancelled' };
919
+ const nativeRequestId = dialog.requestId ?? request.toolUseID ?? randomUUID();
920
+ const result = await requestClaudeUserDialog(dialog, nativeRequestId, signal);
921
+ return signal.aborted ? { behavior: 'cancelled' } : { behavior: 'completed', result };
922
+ },
923
+ canUseTool: async (toolName, input, options) => {
876
924
  if (toolName === 'AskUserQuestion') {
877
- try {
878
- const questionId = randomUUID();
879
- const questions = mapClaudeQuestions(input.questions);
880
- const expiresAt = Date.now() + REPLY_TIMEOUT_MS;
881
- // Create + poll fold onto the shared spine. `responderPolicy: 'infer'`
882
- // keeps the claude host's responder rule byte-identical (explicit
883
- // `cardResponseUserId` wins, else omit — never the manager's ownerId
884
- // fallback), and `native` carries the claude-specific descriptor.
885
- const reply = await config.runtimeInputManager.request('input', conversationId, {
886
- kind: 'clarify',
887
- title: 'Claude needs input',
888
- prompt: questions?.length
889
- ? 'Claude needs your input to continue.'
890
- : 'Claude needs input to continue.',
891
- ...(questions ? { questions } : {}),
892
- sensitive: Boolean(questions?.some((question) => question.isSecret)),
893
- responseUserId: cardResponseUserId,
894
- native: {
895
- runtime: 'claude-code',
896
- method: 'AskUserQuestion',
897
- requestId: questionId,
898
- turnId: session.currentTurnId ?? undefined,
899
- },
900
- turnId: session.currentTurnId ?? undefined,
901
- }, {
902
- requestId: questionId,
903
- expiresAt,
904
- responderPolicy: 'infer',
905
- onCreated: () => {
906
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
907
- session.lastActivity = Date.now();
908
- },
909
- });
910
- if (reply.status !== 'submitted') {
911
- return { behavior: 'deny', message: 'No reply received (timed out)' };
912
- }
913
- return {
914
- behavior: 'allow',
915
- updatedInput: {
916
- ...input,
917
- answers: reply.answers
918
- ? flattenRuntimeInputAnswers(reply.answers)
919
- : reply.value
920
- ? { answer: reply.value }
921
- : {},
922
- },
923
- };
924
- }
925
- catch {
926
- return { behavior: 'deny', message: 'No reply received (timed out)' };
927
- }
925
+ const dialog = parseClaudeAskUserToolInput(input, options.toolUseID);
926
+ if (!dialog)
927
+ return buildClaudeAskUserPermissionDenied('Claude did not provide a valid question.');
928
+ return requestClaudeUserDialog(dialog, options.toolUseID, options.signal);
928
929
  }
929
930
  if (toolName === 'ExitPlanMode') {
930
931
  try {
@@ -965,6 +966,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
965
966
  ...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
966
967
  ...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
967
968
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
969
+ expiresAt: Date.now() + REPLY_TIMEOUT_MS,
968
970
  });
969
971
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
970
972
  const reply = await waitForReply('plan_approval_reply', planId);
@@ -1073,7 +1075,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1073
1075
  environment,
1074
1076
  query: q,
1075
1077
  sendInput,
1076
- enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactRoutingMode),
1078
+ enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, requestingUserId, artifactRoutingMode),
1077
1079
  state: {
1078
1080
  model: undefined,
1079
1081
  permissionMode: config.permissionMode,
@@ -1103,12 +1105,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1103
1105
  lastAcceptedIntent: null,
1104
1106
  lastActivity: Date.now(),
1105
1107
  typingKeepaliveTimer: null,
1108
+ controlInterruptPending: false,
1106
1109
  closed: false,
1107
1110
  sdkSessionId: resumeSessionId || null,
1108
1111
  pendingReply: null,
1109
1112
  writeState: () => writeState(),
1110
1113
  writeTurn: () => writeTurn(),
1111
1114
  clearStreaming: () => clearStreaming(),
1115
+ markInputCompleted: (input) => markInputCompleted(input),
1112
1116
  setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
1113
1117
  clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
1114
1118
  };
@@ -1153,6 +1157,24 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1153
1157
  function clearStreaming() {
1154
1158
  return streamingOutput.clear().catch(() => { });
1155
1159
  }
1160
+ function markInputCompleted(input) {
1161
+ if (input?.kind !== 'canon' || !input.sourceMessageId)
1162
+ return;
1163
+ if (config.runtimeId) {
1164
+ try {
1165
+ saveRuntimeSessionState(config.runtimeId, {
1166
+ conversationId,
1167
+ baseCwd: config.recoveryBaseCwd ?? environment.baseCwd,
1168
+ workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
1169
+ lastInboundMessageId: input.sourceMessageId,
1170
+ });
1171
+ }
1172
+ catch (error) {
1173
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
1174
+ }
1175
+ }
1176
+ config.onInputCompleted?.(input.sourceMessageId);
1177
+ }
1156
1178
  function getFinalTurnTrail() {
1157
1179
  const turnId = session.currentTurnId ?? `claude-${conversationId}`;
1158
1180
  return streamingOutput.getFinalTrail().map((block) => ({ ...block, turnId }));
@@ -1246,6 +1268,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1246
1268
  },
1247
1269
  });
1248
1270
  session.finalizedTurnKeys.add(deliverTurn.turnKey);
1271
+ markInputCompleted(deliverTurn);
1249
1272
  session.state.state = 'idle';
1250
1273
  session.pendingFinalDelivery = null;
1251
1274
  clearFinalDeliveryTimer();
@@ -1458,7 +1481,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1458
1481
  sendInput(input);
1459
1482
  }
1460
1483
  function drainPendingInput() {
1461
- if (session.closed || session.state.state === 'running' || session.pendingInputs.length === 0)
1484
+ if (session.closed
1485
+ || session.controlInterruptPending
1486
+ || session.state.state === 'running'
1487
+ || session.pendingInputs.length === 0)
1462
1488
  return;
1463
1489
  const next = session.pendingInputs.shift();
1464
1490
  if (!next)
@@ -1467,7 +1493,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1467
1493
  writeTurn();
1468
1494
  void startCanonInput(next);
1469
1495
  }
1470
- enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
1496
+ enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => {
1471
1497
  session.lastActivity = Date.now();
1472
1498
  const input = createClaudeInputEnvelope({
1473
1499
  kind: 'canon',
@@ -1476,6 +1502,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1476
1502
  sourceMessageId,
1477
1503
  markAccepted,
1478
1504
  isOwnerTurn,
1505
+ requestingUserId,
1479
1506
  artifactRoutingMode,
1480
1507
  });
1481
1508
  if (session.pendingFinalDelivery) {
@@ -1489,15 +1516,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1489
1516
  return;
1490
1517
  }
1491
1518
  if (session.state.state === 'running' && intent === 'interrupt') {
1492
- if (session.activeInput) {
1493
- session.interruptedTurnKeys.add(session.activeInput.turnKey);
1494
- }
1495
1519
  session.pendingInputs.unshift(input);
1496
- session.turnState = 'interrupted';
1497
- stopVisibleWorkSignal();
1498
- clearStreaming().catch(() => { });
1499
1520
  writeTurn();
1500
- void session.query.interrupt().catch(() => { });
1521
+ const interruptedInput = session.activeInput;
1522
+ void session.query.interrupt().then(() => {
1523
+ if (interruptedInput) {
1524
+ session.interruptedTurnKeys.add(interruptedInput.turnKey);
1525
+ markInputCompleted(interruptedInput);
1526
+ }
1527
+ session.turnState = 'interrupted';
1528
+ stopVisibleWorkSignal();
1529
+ clearStreaming().catch(() => { });
1530
+ writeTurn();
1531
+ }).catch((error) => {
1532
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Input interrupt failed; message remains queued:`, error instanceof Error ? error.message : error);
1533
+ });
1501
1534
  return;
1502
1535
  }
1503
1536
  session.lastAcceptedIntent = intent;
@@ -1644,6 +1677,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1644
1677
  // Non-fatal
1645
1678
  }
1646
1679
  if (finalDelivered) {
1680
+ if (!finalText || !shouldDeliverFinal) {
1681
+ markInputCompleted(completedInput);
1682
+ }
1647
1683
  if (completedInput?.turnKey === session.activeInput?.turnKey) {
1648
1684
  session.activeInput = null;
1649
1685
  }
@@ -1722,9 +1758,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1722
1758
  // Refresh the canonical runtime descriptor after Claude reports supported models.
1723
1759
  try {
1724
1760
  const models = await q.supportedModels();
1725
- const modelList = mergeClaudeModelOptions({
1726
- previous: runtimeModels,
1727
- next: toModelOptions(models),
1761
+ const supplemental = await detectSupplementalRuntimeModels(cwd);
1762
+ const modelList = resolveClaudeModelOptions({
1763
+ discovered: mergeClaudeDiscoveredModelOptions(toModelOptions(models), ...supplemental),
1728
1764
  selectedModel: session.state.model,
1729
1765
  });
1730
1766
  session.availableModels = modelList;
@@ -2192,25 +2228,13 @@ export async function main() {
2192
2228
  });
2193
2229
  }
2194
2230
  else {
2195
- const existingState = loadRuntimeSessionState(runtimeId, {
2196
- conversationId,
2197
- baseCwd: session.environment.baseCwd,
2198
- executionMode: session.environment.mode,
2199
- });
2200
2231
  savedSessionIds.delete(resumeKey);
2201
2232
  clearRuntimeSessionState(runtimeId, {
2202
2233
  conversationId,
2203
2234
  baseCwd: session.environment.baseCwd,
2204
2235
  executionMode: session.environment.mode,
2236
+ preserveInboundCursor: true,
2205
2237
  });
2206
- if (existingState?.lastInboundMessageId) {
2207
- saveRuntimeSessionState(runtimeId, {
2208
- conversationId,
2209
- baseCwd: session.environment.baseCwd,
2210
- executionMode: session.environment.mode,
2211
- lastInboundMessageId: existingState.lastInboundMessageId,
2212
- });
2213
- }
2214
2238
  }
2215
2239
  session.clearStreaming().catch(() => { });
2216
2240
  if (session.idleResetTimer) {
@@ -2234,8 +2258,27 @@ export async function main() {
2234
2258
  }
2235
2259
  async function resetRuntimeSession(session) {
2236
2260
  const conversationId = session.conversationId;
2261
+ const sdkTurnActive = (session.state.state === 'running'
2262
+ || session.state.state === 'requires_action') && !session.pendingFinalDelivery;
2263
+ session.controlInterruptPending = sdkTurnActive;
2264
+ const interruptResult = await confirmClaudeInterrupt({
2265
+ active: sdkTurnActive,
2266
+ interrupt: () => session.query.interrupt(),
2267
+ });
2268
+ if (interruptResult === 'defer') {
2269
+ session.controlInterruptPending = false;
2270
+ throw new Error('Claude Code did not acknowledge the new-session interrupt.');
2271
+ }
2272
+ session.closed = true;
2273
+ if (session.finalDeliveryTimer) {
2274
+ clearTimeout(session.finalDeliveryTimer);
2275
+ session.finalDeliveryTimer = null;
2276
+ }
2237
2277
  const droppedInputs = session.pendingInputs.splice(0);
2238
2278
  await markSessionPendingInputsRejected(conversationId, droppedInputs);
2279
+ session.markInputCompleted(session.activeInput);
2280
+ for (const input of droppedInputs)
2281
+ session.markInputCompleted(input);
2239
2282
  if (session.activeInput) {
2240
2283
  session.interruptedTurnKeys.add(session.activeInput.turnKey);
2241
2284
  }
@@ -2244,9 +2287,6 @@ export async function main() {
2244
2287
  session.turnState = 'interrupted';
2245
2288
  session.clearStreaming().catch(() => { });
2246
2289
  typingSignals.clear(conversationId).catch(() => { });
2247
- if (session.state.state === 'running') {
2248
- await session.query.interrupt().catch(() => { });
2249
- }
2250
2290
  closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
2251
2291
  }
2252
2292
  async function markSessionPendingInputsRejected(conversationId, inputs) {
@@ -2330,10 +2370,17 @@ export async function main() {
2330
2370
  conversationId: convoId,
2331
2371
  baseCwd: existing.environment.baseCwd,
2332
2372
  executionMode: existing.environment.mode,
2373
+ preserveInboundCursor: true,
2333
2374
  });
2334
2375
  }
2335
2376
  }
2336
2377
  existing?.clearStreaming().catch(() => { });
2378
+ if (existing) {
2379
+ settleInboundMessageId(existing.activeInput?.sourceMessageId, false);
2380
+ for (const input of existing.pendingInputs) {
2381
+ settleInboundMessageId(input.sourceMessageId, false);
2382
+ }
2383
+ }
2337
2384
  typingSignals.clear(convoId).catch(() => { });
2338
2385
  typingSignals.dispose(convoId);
2339
2386
  sessions.delete(convoId);
@@ -2354,7 +2401,11 @@ export async function main() {
2354
2401
  runtimeInputManager,
2355
2402
  canRequestApproval: () => canRequestCanonApproval(conversationId),
2356
2403
  runtimeId,
2404
+ recoveryBaseCwd: workingDir,
2357
2405
  ownerId,
2406
+ onInputCompleted: (sourceMessageId) => {
2407
+ settleInboundMessageId(sourceMessageId, true);
2408
+ },
2358
2409
  publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
2359
2410
  }, resumeId);
2360
2411
  sessions.set(conversationId, session);
@@ -2375,9 +2426,27 @@ export async function main() {
2375
2426
  }
2376
2427
  }
2377
2428
  async function enqueueRecoveredInboundMessage(input) {
2378
- const m = input.message;
2429
+ const m = {
2430
+ id: input.message.id,
2431
+ senderId: input.message.senderId,
2432
+ senderName: input.message.senderName,
2433
+ senderType: input.message.senderType,
2434
+ isOwner: input.message.isOwner,
2435
+ text: input.message.text ?? undefined,
2436
+ contentType: input.message.contentType,
2437
+ attachments: input.message.attachments,
2438
+ mentions: input.message.mentions,
2439
+ reactions: input.message.reactions,
2440
+ replyTo: input.message.replyTo ?? undefined,
2441
+ replyToPosition: input.message.replyToPosition ?? undefined,
2442
+ forwarded: input.message.forwarded,
2443
+ forwardedFrom: input.message.forwardedFrom,
2444
+ metadata: input.message.metadata,
2445
+ contactCard: input.message.contactCard,
2446
+ createdAt: input.message.createdAt,
2447
+ };
2379
2448
  if (m.senderId === agentId)
2380
- return;
2449
+ return 'handled';
2381
2450
  let materialized = [];
2382
2451
  if (m.id) {
2383
2452
  try {
@@ -2414,7 +2483,7 @@ export async function main() {
2414
2483
  const autoReply = decideAutoReply(participantContext, behavior);
2415
2484
  if (!autoReply.allow) {
2416
2485
  console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Suppressed recovered auto-reply: ${autoReply.reason}`);
2417
- return;
2486
+ return 'handled';
2418
2487
  }
2419
2488
  markGroupContextModeUsed(input.conversationId, participantContext.groupContextMode);
2420
2489
  const turnMetadata = normalizeTurnMetadata(m.metadata);
@@ -2437,7 +2506,7 @@ export async function main() {
2437
2506
  replyBehavior: 'suppress_auto_reply',
2438
2507
  },
2439
2508
  }).catch(() => { });
2440
- return;
2509
+ return 'handled';
2441
2510
  }
2442
2511
  session.activeSelfContextId = activeSelfContextId;
2443
2512
  const promptText = buildCanonPrompt({
@@ -2463,15 +2532,154 @@ export async function main() {
2463
2532
  content: messageContent,
2464
2533
  },
2465
2534
  parent_tool_use_id: null,
2466
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
2467
- if (m.id) {
2468
- saveRuntimeSessionState(runtimeId, {
2469
- conversationId: input.conversationId,
2470
- baseCwd: workingDir,
2471
- lastInboundMessageId: m.id,
2472
- });
2535
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
2536
+ return 'queued';
2537
+ }
2538
+ const acceptedInboundMessageIds = new Set();
2539
+ const inFlightInboundMessageIds = new Set();
2540
+ function claimInboundMessageId(messageId) {
2541
+ if (!messageId)
2542
+ return true;
2543
+ if (acceptedInboundMessageIds.has(messageId) || inFlightInboundMessageIds.has(messageId))
2544
+ return false;
2545
+ inFlightInboundMessageIds.add(messageId);
2546
+ return true;
2547
+ }
2548
+ function settleInboundMessageId(messageId, accepted) {
2549
+ if (!messageId)
2550
+ return;
2551
+ inFlightInboundMessageIds.delete(messageId);
2552
+ if (!accepted)
2553
+ return;
2554
+ acceptedInboundMessageIds.add(messageId);
2555
+ while (acceptedInboundMessageIds.size > 2_048) {
2556
+ const oldest = acceptedInboundMessageIds.values().next().value;
2557
+ if (!oldest)
2558
+ break;
2559
+ acceptedInboundMessageIds.delete(oldest);
2560
+ }
2561
+ }
2562
+ function handleRuntimeReplyMessage(conversationId, message) {
2563
+ if (message.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
2564
+ const consumed = approvalManager?.handleMessage(conversationId, {
2565
+ senderId: message.senderId,
2566
+ metadata: message.metadata,
2567
+ }) ?? false;
2568
+ if (message.metadata?.type === 'approval_reply') {
2569
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${message.senderName || message.senderId}`);
2570
+ return true;
2571
+ }
2572
+ if (consumed) {
2573
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Approval text reply consumed from ${message.senderName || message.senderId}`);
2574
+ return true;
2575
+ }
2576
+ }
2577
+ if (message.metadata?.type !== 'plan_approval_reply')
2578
+ return false;
2579
+ const existingSession = sessions.get(conversationId);
2580
+ const pendingReply = existingSession?.pendingReply;
2581
+ const isOwnerReply = message.isOwner ?? (ownerId != null && message.senderId === ownerId);
2582
+ const replyRequestId = message.metadata.planId;
2583
+ if (!pendingReply) {
2584
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring stale ${message.metadata.type} with no pending request`);
2585
+ return true;
2586
+ }
2587
+ if (!isOwnerReply) {
2588
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring ${message.metadata.type} from non-owner ${message.senderId}`);
2589
+ return true;
2590
+ }
2591
+ if (pendingReply.type !== message.metadata.type || pendingReply.requestId !== replyRequestId) {
2592
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring mismatched ${message.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
2593
+ return true;
2473
2594
  }
2595
+ existingSession.pendingReply = null;
2596
+ existingSession.lastActivity = Date.now();
2597
+ pendingReply.resolve(message.metadata);
2598
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Reply received (${message.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
2599
+ return true;
2474
2600
  }
2601
+ function persistedConversationCursors(conversationId) {
2602
+ const canonicalCursor = loadRuntimeSessionState(runtimeId, {
2603
+ conversationId,
2604
+ baseCwd: workingDir,
2605
+ workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
2606
+ })?.lastInboundMessageId ?? null;
2607
+ if (canonicalCursor)
2608
+ return { canonicalCursor, legacyCursors: [] };
2609
+ const legacyCursors = Object.values(readLocalRuntimeEntry(runtimeId)?.sessions ?? {})
2610
+ .filter((state) => state.conversationId === conversationId)
2611
+ .map((state) => state.lastInboundMessageId)
2612
+ .filter((messageId) => Boolean(messageId));
2613
+ return { canonicalCursor: null, legacyCursors: [...new Set(legacyCursors)] };
2614
+ }
2615
+ async function performMissedInboundRecovery() {
2616
+ for (const conversationId of knownConversationIds) {
2617
+ try {
2618
+ const cursors = persistedConversationCursors(conversationId);
2619
+ const recovered = await collectMissedInboundMessages({
2620
+ fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
2621
+ cursor: cursors.canonicalCursor,
2622
+ cursorCandidates: cursors.legacyCursors,
2623
+ agentId,
2624
+ });
2625
+ if (!cursors.canonicalCursor
2626
+ && recovered.cursor
2627
+ && recovered.messages.length === 0) {
2628
+ saveRuntimeSessionState(runtimeId, {
2629
+ conversationId,
2630
+ baseCwd: workingDir,
2631
+ workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
2632
+ lastInboundMessageId: recovered.cursor,
2633
+ });
2634
+ }
2635
+ if (recovered.mode === 'truncated-window') {
2636
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Recovery cursor was not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; replaying the bounded recent window`);
2637
+ }
2638
+ for (const message of recovered.messages) {
2639
+ if (!claimInboundMessageId(message.id))
2640
+ continue;
2641
+ try {
2642
+ let queued = false;
2643
+ if (!handleRuntimeReplyMessage(conversationId, message)) {
2644
+ const trigger = shouldTriggerAgentTurn({
2645
+ senderType: message.senderType,
2646
+ metadata: message.metadata,
2647
+ });
2648
+ if (trigger.allow) {
2649
+ queued = await enqueueRecoveredInboundMessage({
2650
+ conversationId,
2651
+ message,
2652
+ hydratedPage: recovered.newestPage,
2653
+ }) === 'queued';
2654
+ }
2655
+ }
2656
+ if (!queued)
2657
+ settleInboundMessageId(message.id, true);
2658
+ }
2659
+ catch (error) {
2660
+ settleInboundMessageId(message.id, false);
2661
+ throw error;
2662
+ }
2663
+ }
2664
+ if (recovered.messages.length > 0) {
2665
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
2666
+ }
2667
+ }
2668
+ catch (error) {
2669
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
2670
+ }
2671
+ }
2672
+ }
2673
+ let recoveryInFlight = null;
2674
+ function recoverMissedInboundMessages() {
2675
+ if (recoveryInFlight)
2676
+ return recoveryInFlight;
2677
+ recoveryInFlight = performMissedInboundRecovery().finally(() => {
2678
+ recoveryInFlight = null;
2679
+ });
2680
+ return recoveryInFlight;
2681
+ }
2682
+ await recoverMissedInboundMessages();
2475
2683
  // ── Connect SSE stream for inbound Canon messages ──
2476
2684
  const stream = new CanonStream({
2477
2685
  apiKey,
@@ -2481,53 +2689,19 @@ export async function main() {
2481
2689
  const m = payload.message;
2482
2690
  if (m.senderId === agentId)
2483
2691
  return;
2692
+ if (!claimInboundMessageId(m.id))
2693
+ return;
2484
2694
  const convoId = payload.conversationId;
2485
2695
  knownConversationIds.add(convoId);
2486
- if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
2487
- const consumed = approvalManager?.handleMessage(convoId, {
2488
- senderId: m.senderId,
2489
- metadata: m.metadata,
2490
- }) ?? false;
2491
- if (m.metadata?.type === 'approval_reply') {
2492
- console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
2493
- return;
2494
- }
2495
- if (consumed) {
2496
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
2497
- return;
2498
- }
2499
- }
2500
- // Check for metadata replies (plan approvals).
2501
- if (m.metadata?.type === 'plan_approval_reply') {
2502
- const existingSession = sessions.get(convoId);
2503
- const pendingReply = existingSession?.pendingReply;
2504
- const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2505
- const replyRequestId = m.metadata.planId;
2506
- if (!pendingReply) {
2507
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
2508
- return;
2509
- }
2510
- if (!isOwnerReply) {
2511
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
2512
- return;
2513
- }
2514
- if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
2515
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
2516
- return;
2517
- }
2518
- if (existingSession?.pendingReply) {
2519
- const { resolve } = existingSession.pendingReply;
2520
- existingSession.pendingReply = null;
2521
- existingSession.lastActivity = Date.now();
2522
- resolve(m.metadata);
2523
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
2524
- return;
2525
- }
2696
+ if (handleRuntimeReplyMessage(convoId, m)) {
2697
+ settleInboundMessageId(m.id, true);
2698
+ return;
2526
2699
  }
2527
2700
  const sender = m.senderName || m.senderId;
2528
2701
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2529
2702
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2530
2703
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2704
+ settleInboundMessageId(m.id, true);
2531
2705
  return;
2532
2706
  }
2533
2707
  void (async () => {
@@ -2572,7 +2746,7 @@ export async function main() {
2572
2746
  : decideAutoReply(participantContext, behavior);
2573
2747
  if (!autoReply.allow) {
2574
2748
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
2575
- return;
2749
+ return false;
2576
2750
  }
2577
2751
  markGroupContextModeUsed(convoId, participantContext.groupContextMode);
2578
2752
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
@@ -2596,7 +2770,7 @@ export async function main() {
2596
2770
  replyBehavior: 'suppress_auto_reply',
2597
2771
  },
2598
2772
  }).catch(() => { });
2599
- return;
2773
+ return false;
2600
2774
  }
2601
2775
  session.activeSelfContextId = activeSelfContextId;
2602
2776
  const promptText = buildCanonPrompt({
@@ -2622,15 +2796,15 @@ export async function main() {
2622
2796
  content: messageContent,
2623
2797
  },
2624
2798
  parent_tool_use_id: null,
2625
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
2626
- if (m.id) {
2627
- saveRuntimeSessionState(runtimeId, {
2628
- conversationId: convoId,
2629
- baseCwd: workingDir,
2630
- lastInboundMessageId: m.id,
2631
- });
2632
- }
2633
- })();
2799
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
2800
+ return true;
2801
+ })().then((queued) => {
2802
+ if (!queued)
2803
+ settleInboundMessageId(m.id, true);
2804
+ }).catch((error) => {
2805
+ settleInboundMessageId(m.id, false);
2806
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
2807
+ });
2634
2808
  },
2635
2809
  onMessageDeleted: (payload) => {
2636
2810
  removeQueuedInput(payload.conversationId, payload.messageId);
@@ -2641,6 +2815,7 @@ export async function main() {
2641
2815
  onConnected: () => {
2642
2816
  streamConnected = true;
2643
2817
  void publishRuntimeHeartbeat();
2818
+ void recoverMissedInboundMessages();
2644
2819
  console.error('[canon-host] SSE connected');
2645
2820
  },
2646
2821
  onDisconnected: () => {
@@ -2687,31 +2862,57 @@ export async function main() {
2687
2862
  });
2688
2863
  session.writeState();
2689
2864
  }
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.
2865
+ // Stop/new-session signals remain pending until the Claude SDK confirms the
2866
+ // interrupt. `defer` deliberately makes the poller revisit the same node.
2693
2867
  async function handleRuntimeControlSignal(event) {
2694
2868
  const convoId = event.conversationId;
2695
2869
  const session = sessions.get(convoId);
2696
2870
  if (!session || session.closed)
2697
- return { consume: false };
2871
+ return { defer: true };
2698
2872
  if (event.type === 'new_session') {
2699
2873
  console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
2700
- await resetRuntimeSession(session);
2874
+ try {
2875
+ await resetRuntimeSession(session);
2876
+ }
2877
+ catch (error) {
2878
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] New-session interrupt failed; retrying signal:`, error instanceof Error ? error.message : error);
2879
+ return { defer: true };
2880
+ }
2701
2881
  return;
2702
2882
  }
2703
- const isActive = session.state.state === 'running';
2883
+ const isActive = session.state.state === 'running' || session.state.state === 'requires_action';
2704
2884
  if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
2705
2885
  return; // Nothing to interrupt — consume the stale signal
2706
2886
  }
2707
2887
  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);
2888
+ const sdkTurnActive = isActive && !session.pendingFinalDelivery;
2889
+ session.controlInterruptPending = sdkTurnActive;
2890
+ const interruptResult = await confirmClaudeInterrupt({
2891
+ active: sdkTurnActive,
2892
+ interrupt: () => session.query.interrupt(),
2893
+ });
2894
+ if (interruptResult === 'defer') {
2895
+ session.controlInterruptPending = false;
2896
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Stop interrupt failed; retrying signal`);
2897
+ return { defer: true };
2898
+ }
2899
+ const droppedInputs = event.type === 'stop_and_drop'
2900
+ ? session.pendingInputs.splice(0)
2901
+ : [];
2902
+ if (droppedInputs.length > 0) {
2710
2903
  await markSessionPendingInputsRejected(convoId, droppedInputs);
2711
2904
  }
2905
+ session.markInputCompleted(session.activeInput);
2906
+ for (const input of droppedInputs)
2907
+ session.markInputCompleted(input);
2712
2908
  if (session.activeInput) {
2713
2909
  session.interruptedTurnKeys.add(session.activeInput.turnKey);
2714
2910
  }
2911
+ if (session.finalDeliveryTimer) {
2912
+ clearTimeout(session.finalDeliveryTimer);
2913
+ session.finalDeliveryTimer = null;
2914
+ }
2915
+ session.pendingFinalDelivery = null;
2715
2916
  session.pendingFinalText = null;
2716
2917
  session.turnState = 'interrupted';
2717
2918
  session.clearStreaming().catch(() => { });
@@ -2726,9 +2927,7 @@ export async function main() {
2726
2927
  ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
2727
2928
  completedAt: { '.sv': 'timestamp' },
2728
2929
  }).catch(() => { });
2729
- if (isActive) {
2730
- await session.query.interrupt();
2731
- }
2930
+ session.controlInterruptPending = false;
2732
2931
  }
2733
2932
  controlPoller.start();
2734
2933
  // ── Heartbeat (per-session) ──