@canonmsg/claude-code-plugin 0.25.0 → 0.25.3

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, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
37
+ import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } 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,
@@ -768,7 +738,6 @@ async function buildCanonUserContent(input) {
768
738
  // ── Session factory ─────────────────────────────────────────────────
769
739
  function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
770
740
  const { cwd } = environment;
771
- const cardResponseUserId = config.ownerId ?? undefined;
772
741
  // Per-session input stream with message queue
773
742
  let resolveInput = null;
774
743
  let inputDone = false;
@@ -784,7 +753,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
784
753
  messageQueue.push(input);
785
754
  }
786
755
  };
787
- let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
756
+ let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => {
788
757
  sendInput(createClaudeInputEnvelope({
789
758
  kind: 'canon',
790
759
  msg,
@@ -792,6 +761,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
792
761
  sourceMessageId,
793
762
  markAccepted,
794
763
  isOwnerTurn,
764
+ requestingUserId,
795
765
  artifactRoutingMode,
796
766
  }));
797
767
  };
@@ -825,7 +795,53 @@ function createSession(conversationId, environment, agentId, client, typingSigna
825
795
  // eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
826
796
  let session;
827
797
  const REPLY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
828
- function waitForReply(type, requestId) {
798
+ const coordinateUserDialog = createClaudeUserDialogCoordinator();
799
+ async function requestClaudeUserDialog(dialog, nativeRequestId, signal) {
800
+ const questionId = /^[A-Za-z0-9_.:-]{1,160}$/.test(nativeRequestId)
801
+ ? nativeRequestId
802
+ : randomUUID();
803
+ const responseRouting = resolveClaudeTurnResponseRouting(session.activeInput, config.ownerId, dialog.questions.some((question) => question.isSecret));
804
+ const responseUserId = responseRouting.responseUserId;
805
+ if (!responseUserId) {
806
+ return buildClaudeAskUserPermissionDenied('No eligible human participant can answer this question.');
807
+ }
808
+ return coordinateUserDialog(questionId, async () => {
809
+ try {
810
+ const reply = await config.runtimeInputManager.request('input', conversationId, {
811
+ kind: 'clarify',
812
+ title: 'Claude needs input',
813
+ prompt: 'Claude needs your input to continue.',
814
+ questions: dialog.questions,
815
+ sensitive: dialog.questions.some((question) => question.isSecret),
816
+ responseUserId,
817
+ native: {
818
+ runtime: 'claude-code',
819
+ method: 'AskUserQuestion',
820
+ requestId: nativeRequestId,
821
+ turnId: session.currentTurnId ?? undefined,
822
+ },
823
+ turnId: session.currentTurnId ?? undefined,
824
+ }, {
825
+ requestId: questionId,
826
+ expiresAt: Date.now() + REPLY_TIMEOUT_MS,
827
+ responderPolicy: 'infer',
828
+ signal,
829
+ onCreated: () => {
830
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
831
+ session.lastActivity = Date.now();
832
+ },
833
+ });
834
+ if (reply.status === 'submitted') {
835
+ return buildClaudeAskUserPermissionResult(dialog, reply);
836
+ }
837
+ return buildClaudeAskUserPermissionDenied(reply.status === 'timeout' ? 'No reply received (timed out)' : 'The user cancelled this question.');
838
+ }
839
+ catch {
840
+ return buildClaudeAskUserPermissionDenied(signal.aborted ? 'The question was interrupted.' : 'No reply received (timed out)');
841
+ }
842
+ });
843
+ }
844
+ function waitForReply(type, requestId, responseUserId) {
829
845
  // Keep session alive while waiting for user input
830
846
  session.lastActivity = Date.now();
831
847
  return new Promise((resolve, reject) => {
@@ -836,6 +852,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
836
852
  session.pendingReply = {
837
853
  type,
838
854
  requestId,
855
+ responseUserId,
839
856
  resolve: (metadata) => {
840
857
  clearTimeout(timer);
841
858
  resolve(metadata);
@@ -857,6 +874,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
857
874
  const queryOptions = {
858
875
  systemPrompt: { type: 'preset', preset: 'claude_code' },
859
876
  tools: { type: 'preset', preset: 'claude_code' },
877
+ model: 'default',
860
878
  permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
861
879
  allowDangerouslySkipPermissions: true,
862
880
  cwd,
@@ -872,61 +890,30 @@ function createSession(conversationId, environment, agentId, client, typingSigna
872
890
  console.error(`[claude-cli] [${conversationId.slice(0, 8)}] ${trimmed}`);
873
891
  }
874
892
  },
875
- canUseTool: async (toolName, input) => {
893
+ supportedDialogKinds: [...CLAUDE_SUPPORTED_DIALOG_KINDS],
894
+ onUserDialog: async (request, { signal }) => {
895
+ const dialog = parseClaudeAskUserDialog(request);
896
+ if (!dialog)
897
+ return { behavior: 'cancelled' };
898
+ const nativeRequestId = resolveClaudeUserDialogRequestId(request, dialog) ?? randomUUID();
899
+ const result = await requestClaudeUserDialog(dialog, nativeRequestId, signal);
900
+ return signal.aborted ? { behavior: 'cancelled' } : { behavior: 'completed', result };
901
+ },
902
+ canUseTool: async (toolName, input, options) => {
876
903
  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
- }
904
+ const dialog = parseClaudeAskUserToolInput(input, options.toolUseID);
905
+ if (!dialog)
906
+ return buildClaudeAskUserPermissionDenied('Claude did not provide a valid question.');
907
+ return requestClaudeUserDialog(dialog, options.toolUseID, options.signal);
908
+ }
909
+ const turnResponseRouting = resolveClaudeTurnResponseRouting(session.activeInput, config.ownerId);
910
+ if (toolName === 'ExitPlanMode') {
911
+ if (!turnResponseRouting.responseUserId) {
913
912
  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
- },
913
+ behavior: 'deny',
914
+ message: 'No eligible human participant can review this plan.',
923
915
  };
924
916
  }
925
- catch {
926
- return { behavior: 'deny', message: 'No reply received (timed out)' };
927
- }
928
- }
929
- if (toolName === 'ExitPlanMode') {
930
917
  try {
931
918
  const planId = randomUUID();
932
919
  const allowedPrompts = Array.isArray(input.allowedPrompts)
@@ -962,21 +949,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
962
949
  title: 'Plan ready for review.',
963
950
  summary: 'Claude has prepared a plan and is asking permission to continue.',
964
951
  ...(planBody ? { body: planBody } : {}),
965
- ...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
952
+ responseUserId: turnResponseRouting.responseUserId,
966
953
  ...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
967
954
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
955
+ expiresAt: Date.now() + REPLY_TIMEOUT_MS,
968
956
  });
969
957
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
970
- const reply = await waitForReply('plan_approval_reply', planId);
958
+ const reply = await waitForReply('plan_approval_reply', planId, turnResponseRouting.responseUserId);
971
959
  if (reply.decision === 'approve') {
972
960
  // Honor opt-in selective grants: when the card sent an explicit
973
961
  // granted subset, pre-approve those tools as session rules so they
974
962
  // do not re-prompt. Owner-turn only; absent grants preserve the
975
963
  // legacy behavior (each tool still prompts individually).
976
- const isOwnerTurn = session.activeInput?.kind === 'canon'
977
- ? session.activeInput.isOwnerTurn === true
978
- : true;
979
- if (isOwnerTurn && config.approvalManager && Array.isArray(reply.grantedPrompts)) {
964
+ if (turnResponseRouting.allowSessionRule
965
+ && config.approvalManager
966
+ && Array.isArray(reply.grantedPrompts)) {
980
967
  // Only pre-approve tools the plan actually offered, and bound the
981
968
  // count/length to match parseSessionRule's caps — grantedPrompts
982
969
  // is untrusted wire metadata, not the validated approval path.
@@ -1036,9 +1023,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1036
1023
  conversationId,
1037
1024
  runtimeId: config.runtimeId,
1038
1025
  turnId: session.currentTurnId,
1039
- isOwnerTurn: session.activeInput?.kind === 'canon'
1040
- ? session.activeInput.isOwnerTurn === true
1041
- : true,
1026
+ isOwnerTurn: turnResponseRouting.isOwnerTurn,
1027
+ responseUserId: turnResponseRouting.responseUserId,
1028
+ allowSessionRule: turnResponseRouting.allowSessionRule,
1042
1029
  approvalManager: config.approvalManager,
1043
1030
  canRequestApproval: config.canRequestApproval,
1044
1031
  allowedNonOwnerTools: allowedNonOwnerClaudeTools,
@@ -1073,7 +1060,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1073
1060
  environment,
1074
1061
  query: q,
1075
1062
  sendInput,
1076
- enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactRoutingMode),
1063
+ enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, requestingUserId, artifactRoutingMode),
1077
1064
  state: {
1078
1065
  model: undefined,
1079
1066
  permissionMode: config.permissionMode,
@@ -1103,12 +1090,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1103
1090
  lastAcceptedIntent: null,
1104
1091
  lastActivity: Date.now(),
1105
1092
  typingKeepaliveTimer: null,
1093
+ controlInterruptPending: false,
1106
1094
  closed: false,
1107
1095
  sdkSessionId: resumeSessionId || null,
1108
1096
  pendingReply: null,
1109
1097
  writeState: () => writeState(),
1110
1098
  writeTurn: () => writeTurn(),
1111
1099
  clearStreaming: () => clearStreaming(),
1100
+ markInputCompleted: (input) => markInputCompleted(input),
1112
1101
  setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
1113
1102
  clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
1114
1103
  };
@@ -1153,6 +1142,24 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1153
1142
  function clearStreaming() {
1154
1143
  return streamingOutput.clear().catch(() => { });
1155
1144
  }
1145
+ function markInputCompleted(input) {
1146
+ if (input?.kind !== 'canon' || !input.sourceMessageId)
1147
+ return;
1148
+ if (config.runtimeId) {
1149
+ try {
1150
+ saveRuntimeSessionState(config.runtimeId, {
1151
+ conversationId,
1152
+ baseCwd: config.recoveryBaseCwd ?? environment.baseCwd,
1153
+ workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
1154
+ lastInboundMessageId: input.sourceMessageId,
1155
+ });
1156
+ }
1157
+ catch (error) {
1158
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
1159
+ }
1160
+ }
1161
+ config.onInputCompleted?.(input.sourceMessageId);
1162
+ }
1156
1163
  function getFinalTurnTrail() {
1157
1164
  const turnId = session.currentTurnId ?? `claude-${conversationId}`;
1158
1165
  return streamingOutput.getFinalTrail().map((block) => ({ ...block, turnId }));
@@ -1246,6 +1253,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1246
1253
  },
1247
1254
  });
1248
1255
  session.finalizedTurnKeys.add(deliverTurn.turnKey);
1256
+ markInputCompleted(deliverTurn);
1249
1257
  session.state.state = 'idle';
1250
1258
  session.pendingFinalDelivery = null;
1251
1259
  clearFinalDeliveryTimer();
@@ -1458,7 +1466,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1458
1466
  sendInput(input);
1459
1467
  }
1460
1468
  function drainPendingInput() {
1461
- if (session.closed || session.state.state === 'running' || session.pendingInputs.length === 0)
1469
+ if (session.closed
1470
+ || session.controlInterruptPending
1471
+ || session.state.state === 'running'
1472
+ || session.pendingInputs.length === 0)
1462
1473
  return;
1463
1474
  const next = session.pendingInputs.shift();
1464
1475
  if (!next)
@@ -1467,7 +1478,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1467
1478
  writeTurn();
1468
1479
  void startCanonInput(next);
1469
1480
  }
1470
- enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
1481
+ enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => {
1471
1482
  session.lastActivity = Date.now();
1472
1483
  const input = createClaudeInputEnvelope({
1473
1484
  kind: 'canon',
@@ -1476,6 +1487,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1476
1487
  sourceMessageId,
1477
1488
  markAccepted,
1478
1489
  isOwnerTurn,
1490
+ requestingUserId,
1479
1491
  artifactRoutingMode,
1480
1492
  });
1481
1493
  if (session.pendingFinalDelivery) {
@@ -1489,15 +1501,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1489
1501
  return;
1490
1502
  }
1491
1503
  if (session.state.state === 'running' && intent === 'interrupt') {
1492
- if (session.activeInput) {
1493
- session.interruptedTurnKeys.add(session.activeInput.turnKey);
1494
- }
1495
1504
  session.pendingInputs.unshift(input);
1496
- session.turnState = 'interrupted';
1497
- stopVisibleWorkSignal();
1498
- clearStreaming().catch(() => { });
1499
1505
  writeTurn();
1500
- void session.query.interrupt().catch(() => { });
1506
+ const interruptedInput = session.activeInput;
1507
+ void session.query.interrupt().then(() => {
1508
+ if (interruptedInput) {
1509
+ session.interruptedTurnKeys.add(interruptedInput.turnKey);
1510
+ markInputCompleted(interruptedInput);
1511
+ }
1512
+ session.turnState = 'interrupted';
1513
+ stopVisibleWorkSignal();
1514
+ clearStreaming().catch(() => { });
1515
+ writeTurn();
1516
+ }).catch((error) => {
1517
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Input interrupt failed; message remains queued:`, error instanceof Error ? error.message : error);
1518
+ });
1501
1519
  return;
1502
1520
  }
1503
1521
  session.lastAcceptedIntent = intent;
@@ -1644,6 +1662,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1644
1662
  // Non-fatal
1645
1663
  }
1646
1664
  if (finalDelivered) {
1665
+ if (!finalText || !shouldDeliverFinal) {
1666
+ markInputCompleted(completedInput);
1667
+ }
1647
1668
  if (completedInput?.turnKey === session.activeInput?.turnKey) {
1648
1669
  session.activeInput = null;
1649
1670
  }
@@ -1722,9 +1743,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1722
1743
  // Refresh the canonical runtime descriptor after Claude reports supported models.
1723
1744
  try {
1724
1745
  const models = await q.supportedModels();
1725
- const modelList = mergeClaudeModelOptions({
1726
- previous: runtimeModels,
1727
- next: toModelOptions(models),
1746
+ const supplemental = await detectSupplementalRuntimeModels(cwd);
1747
+ const modelList = resolveClaudeModelOptions({
1748
+ discovered: mergeClaudeDiscoveredModelOptions(toModelOptions(models), ...supplemental),
1728
1749
  selectedModel: session.state.model,
1729
1750
  });
1730
1751
  session.availableModels = modelList;
@@ -2192,25 +2213,13 @@ export async function main() {
2192
2213
  });
2193
2214
  }
2194
2215
  else {
2195
- const existingState = loadRuntimeSessionState(runtimeId, {
2196
- conversationId,
2197
- baseCwd: session.environment.baseCwd,
2198
- executionMode: session.environment.mode,
2199
- });
2200
2216
  savedSessionIds.delete(resumeKey);
2201
2217
  clearRuntimeSessionState(runtimeId, {
2202
2218
  conversationId,
2203
2219
  baseCwd: session.environment.baseCwd,
2204
2220
  executionMode: session.environment.mode,
2221
+ preserveInboundCursor: true,
2205
2222
  });
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
2223
  }
2215
2224
  session.clearStreaming().catch(() => { });
2216
2225
  if (session.idleResetTimer) {
@@ -2234,8 +2243,27 @@ export async function main() {
2234
2243
  }
2235
2244
  async function resetRuntimeSession(session) {
2236
2245
  const conversationId = session.conversationId;
2246
+ const sdkTurnActive = (session.state.state === 'running'
2247
+ || session.state.state === 'requires_action') && !session.pendingFinalDelivery;
2248
+ session.controlInterruptPending = sdkTurnActive;
2249
+ const interruptResult = await confirmClaudeInterrupt({
2250
+ active: sdkTurnActive,
2251
+ interrupt: () => session.query.interrupt(),
2252
+ });
2253
+ if (interruptResult === 'defer') {
2254
+ session.controlInterruptPending = false;
2255
+ throw new Error('Claude Code did not acknowledge the new-session interrupt.');
2256
+ }
2257
+ session.closed = true;
2258
+ if (session.finalDeliveryTimer) {
2259
+ clearTimeout(session.finalDeliveryTimer);
2260
+ session.finalDeliveryTimer = null;
2261
+ }
2237
2262
  const droppedInputs = session.pendingInputs.splice(0);
2238
2263
  await markSessionPendingInputsRejected(conversationId, droppedInputs);
2264
+ session.markInputCompleted(session.activeInput);
2265
+ for (const input of droppedInputs)
2266
+ session.markInputCompleted(input);
2239
2267
  if (session.activeInput) {
2240
2268
  session.interruptedTurnKeys.add(session.activeInput.turnKey);
2241
2269
  }
@@ -2244,9 +2272,6 @@ export async function main() {
2244
2272
  session.turnState = 'interrupted';
2245
2273
  session.clearStreaming().catch(() => { });
2246
2274
  typingSignals.clear(conversationId).catch(() => { });
2247
- if (session.state.state === 'running') {
2248
- await session.query.interrupt().catch(() => { });
2249
- }
2250
2275
  closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
2251
2276
  }
2252
2277
  async function markSessionPendingInputsRejected(conversationId, inputs) {
@@ -2330,10 +2355,17 @@ export async function main() {
2330
2355
  conversationId: convoId,
2331
2356
  baseCwd: existing.environment.baseCwd,
2332
2357
  executionMode: existing.environment.mode,
2358
+ preserveInboundCursor: true,
2333
2359
  });
2334
2360
  }
2335
2361
  }
2336
2362
  existing?.clearStreaming().catch(() => { });
2363
+ if (existing) {
2364
+ settleInboundMessageId(existing.activeInput?.sourceMessageId, false);
2365
+ for (const input of existing.pendingInputs) {
2366
+ settleInboundMessageId(input.sourceMessageId, false);
2367
+ }
2368
+ }
2337
2369
  typingSignals.clear(convoId).catch(() => { });
2338
2370
  typingSignals.dispose(convoId);
2339
2371
  sessions.delete(convoId);
@@ -2354,7 +2386,11 @@ export async function main() {
2354
2386
  runtimeInputManager,
2355
2387
  canRequestApproval: () => canRequestCanonApproval(conversationId),
2356
2388
  runtimeId,
2389
+ recoveryBaseCwd: workingDir,
2357
2390
  ownerId,
2391
+ onInputCompleted: (sourceMessageId) => {
2392
+ settleInboundMessageId(sourceMessageId, true);
2393
+ },
2358
2394
  publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
2359
2395
  }, resumeId);
2360
2396
  sessions.set(conversationId, session);
@@ -2375,9 +2411,27 @@ export async function main() {
2375
2411
  }
2376
2412
  }
2377
2413
  async function enqueueRecoveredInboundMessage(input) {
2378
- const m = input.message;
2414
+ const m = {
2415
+ id: input.message.id,
2416
+ senderId: input.message.senderId,
2417
+ senderName: input.message.senderName,
2418
+ senderType: input.message.senderType,
2419
+ isOwner: input.message.isOwner,
2420
+ text: input.message.text ?? undefined,
2421
+ contentType: input.message.contentType,
2422
+ attachments: input.message.attachments,
2423
+ mentions: input.message.mentions,
2424
+ reactions: input.message.reactions,
2425
+ replyTo: input.message.replyTo ?? undefined,
2426
+ replyToPosition: input.message.replyToPosition ?? undefined,
2427
+ forwarded: input.message.forwarded,
2428
+ forwardedFrom: input.message.forwardedFrom,
2429
+ metadata: input.message.metadata,
2430
+ contactCard: input.message.contactCard,
2431
+ createdAt: input.message.createdAt,
2432
+ };
2379
2433
  if (m.senderId === agentId)
2380
- return;
2434
+ return 'handled';
2381
2435
  let materialized = [];
2382
2436
  if (m.id) {
2383
2437
  try {
@@ -2414,7 +2468,7 @@ export async function main() {
2414
2468
  const autoReply = decideAutoReply(participantContext, behavior);
2415
2469
  if (!autoReply.allow) {
2416
2470
  console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Suppressed recovered auto-reply: ${autoReply.reason}`);
2417
- return;
2471
+ return 'handled';
2418
2472
  }
2419
2473
  markGroupContextModeUsed(input.conversationId, participantContext.groupContextMode);
2420
2474
  const turnMetadata = normalizeTurnMetadata(m.metadata);
@@ -2437,7 +2491,7 @@ export async function main() {
2437
2491
  replyBehavior: 'suppress_auto_reply',
2438
2492
  },
2439
2493
  }).catch(() => { });
2440
- return;
2494
+ return 'handled';
2441
2495
  }
2442
2496
  session.activeSelfContextId = activeSelfContextId;
2443
2497
  const promptText = buildCanonPrompt({
@@ -2463,15 +2517,153 @@ export async function main() {
2463
2517
  content: messageContent,
2464
2518
  },
2465
2519
  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
- });
2520
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
2521
+ return 'queued';
2522
+ }
2523
+ const acceptedInboundMessageIds = new Set();
2524
+ const inFlightInboundMessageIds = new Set();
2525
+ function claimInboundMessageId(messageId) {
2526
+ if (!messageId)
2527
+ return true;
2528
+ if (acceptedInboundMessageIds.has(messageId) || inFlightInboundMessageIds.has(messageId))
2529
+ return false;
2530
+ inFlightInboundMessageIds.add(messageId);
2531
+ return true;
2532
+ }
2533
+ function settleInboundMessageId(messageId, accepted) {
2534
+ if (!messageId)
2535
+ return;
2536
+ inFlightInboundMessageIds.delete(messageId);
2537
+ if (!accepted)
2538
+ return;
2539
+ acceptedInboundMessageIds.add(messageId);
2540
+ while (acceptedInboundMessageIds.size > 2_048) {
2541
+ const oldest = acceptedInboundMessageIds.values().next().value;
2542
+ if (!oldest)
2543
+ break;
2544
+ acceptedInboundMessageIds.delete(oldest);
2545
+ }
2546
+ }
2547
+ function handleRuntimeReplyMessage(conversationId, message) {
2548
+ if (message.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
2549
+ const consumed = approvalManager?.handleMessage(conversationId, {
2550
+ senderId: message.senderId,
2551
+ metadata: message.metadata,
2552
+ }) ?? false;
2553
+ if (message.metadata?.type === 'approval_reply') {
2554
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${message.senderName || message.senderId}`);
2555
+ return true;
2556
+ }
2557
+ if (consumed) {
2558
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Approval text reply consumed from ${message.senderName || message.senderId}`);
2559
+ return true;
2560
+ }
2561
+ }
2562
+ if (message.metadata?.type !== 'plan_approval_reply')
2563
+ return false;
2564
+ const existingSession = sessions.get(conversationId);
2565
+ const pendingReply = existingSession?.pendingReply;
2566
+ const replyRequestId = message.metadata.planId;
2567
+ if (!pendingReply) {
2568
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring stale ${message.metadata.type} with no pending request`);
2569
+ return true;
2570
+ }
2571
+ if (message.senderId !== pendingReply.responseUserId) {
2572
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring ${message.metadata.type} from unexpected responder ${message.senderId}`);
2573
+ return true;
2574
+ }
2575
+ if (pendingReply.type !== message.metadata.type || pendingReply.requestId !== replyRequestId) {
2576
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring mismatched ${message.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
2577
+ return true;
2473
2578
  }
2579
+ existingSession.pendingReply = null;
2580
+ existingSession.lastActivity = Date.now();
2581
+ pendingReply.resolve(message.metadata);
2582
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Reply received (${message.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
2583
+ return true;
2474
2584
  }
2585
+ function persistedConversationCursors(conversationId) {
2586
+ const canonicalCursor = loadRuntimeSessionState(runtimeId, {
2587
+ conversationId,
2588
+ baseCwd: workingDir,
2589
+ workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
2590
+ })?.lastInboundMessageId ?? null;
2591
+ if (canonicalCursor)
2592
+ return { canonicalCursor, legacyCursors: [] };
2593
+ const legacyCursors = Object.values(readLocalRuntimeEntry(runtimeId)?.sessions ?? {})
2594
+ .filter((state) => state.conversationId === conversationId)
2595
+ .map((state) => state.lastInboundMessageId)
2596
+ .filter((messageId) => Boolean(messageId));
2597
+ return { canonicalCursor: null, legacyCursors: [...new Set(legacyCursors)] };
2598
+ }
2599
+ async function performMissedInboundRecovery() {
2600
+ for (const conversationId of knownConversationIds) {
2601
+ try {
2602
+ const cursors = persistedConversationCursors(conversationId);
2603
+ const recovered = await collectMissedInboundMessages({
2604
+ fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
2605
+ cursor: cursors.canonicalCursor,
2606
+ cursorCandidates: cursors.legacyCursors,
2607
+ agentId,
2608
+ });
2609
+ if (!cursors.canonicalCursor
2610
+ && recovered.cursor
2611
+ && recovered.messages.length === 0) {
2612
+ saveRuntimeSessionState(runtimeId, {
2613
+ conversationId,
2614
+ baseCwd: workingDir,
2615
+ workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
2616
+ lastInboundMessageId: recovered.cursor,
2617
+ });
2618
+ }
2619
+ if (recovered.mode === 'truncated-window') {
2620
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Recovery cursor was not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; replaying the bounded recent window`);
2621
+ }
2622
+ for (const message of recovered.messages) {
2623
+ if (!claimInboundMessageId(message.id))
2624
+ continue;
2625
+ try {
2626
+ let queued = false;
2627
+ if (!handleRuntimeReplyMessage(conversationId, message)) {
2628
+ const trigger = shouldTriggerAgentTurn({
2629
+ senderType: message.senderType,
2630
+ metadata: message.metadata,
2631
+ });
2632
+ if (trigger.allow) {
2633
+ queued = await enqueueRecoveredInboundMessage({
2634
+ conversationId,
2635
+ message,
2636
+ hydratedPage: recovered.newestPage,
2637
+ }) === 'queued';
2638
+ }
2639
+ }
2640
+ if (!queued)
2641
+ settleInboundMessageId(message.id, true);
2642
+ }
2643
+ catch (error) {
2644
+ settleInboundMessageId(message.id, false);
2645
+ throw error;
2646
+ }
2647
+ }
2648
+ if (recovered.messages.length > 0) {
2649
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
2650
+ }
2651
+ }
2652
+ catch (error) {
2653
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
2654
+ }
2655
+ }
2656
+ }
2657
+ let recoveryInFlight = null;
2658
+ function recoverMissedInboundMessages() {
2659
+ if (recoveryInFlight)
2660
+ return recoveryInFlight;
2661
+ recoveryInFlight = performMissedInboundRecovery().finally(() => {
2662
+ recoveryInFlight = null;
2663
+ });
2664
+ return recoveryInFlight;
2665
+ }
2666
+ await recoverMissedInboundMessages();
2475
2667
  // ── Connect SSE stream for inbound Canon messages ──
2476
2668
  const stream = new CanonStream({
2477
2669
  apiKey,
@@ -2481,53 +2673,19 @@ export async function main() {
2481
2673
  const m = payload.message;
2482
2674
  if (m.senderId === agentId)
2483
2675
  return;
2676
+ if (!claimInboundMessageId(m.id))
2677
+ return;
2484
2678
  const convoId = payload.conversationId;
2485
2679
  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
- }
2680
+ if (handleRuntimeReplyMessage(convoId, m)) {
2681
+ settleInboundMessageId(m.id, true);
2682
+ return;
2526
2683
  }
2527
2684
  const sender = m.senderName || m.senderId;
2528
2685
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2529
2686
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2530
2687
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2688
+ settleInboundMessageId(m.id, true);
2531
2689
  return;
2532
2690
  }
2533
2691
  void (async () => {
@@ -2572,7 +2730,7 @@ export async function main() {
2572
2730
  : decideAutoReply(participantContext, behavior);
2573
2731
  if (!autoReply.allow) {
2574
2732
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
2575
- return;
2733
+ return false;
2576
2734
  }
2577
2735
  markGroupContextModeUsed(convoId, participantContext.groupContextMode);
2578
2736
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
@@ -2596,7 +2754,7 @@ export async function main() {
2596
2754
  replyBehavior: 'suppress_auto_reply',
2597
2755
  },
2598
2756
  }).catch(() => { });
2599
- return;
2757
+ return false;
2600
2758
  }
2601
2759
  session.activeSelfContextId = activeSelfContextId;
2602
2760
  const promptText = buildCanonPrompt({
@@ -2622,15 +2780,15 @@ export async function main() {
2622
2780
  content: messageContent,
2623
2781
  },
2624
2782
  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
- })();
2783
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
2784
+ return true;
2785
+ })().then((queued) => {
2786
+ if (!queued)
2787
+ settleInboundMessageId(m.id, true);
2788
+ }).catch((error) => {
2789
+ settleInboundMessageId(m.id, false);
2790
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
2791
+ });
2634
2792
  },
2635
2793
  onMessageDeleted: (payload) => {
2636
2794
  removeQueuedInput(payload.conversationId, payload.messageId);
@@ -2641,6 +2799,7 @@ export async function main() {
2641
2799
  onConnected: () => {
2642
2800
  streamConnected = true;
2643
2801
  void publishRuntimeHeartbeat();
2802
+ void recoverMissedInboundMessages();
2644
2803
  console.error('[canon-host] SSE connected');
2645
2804
  },
2646
2805
  onDisconnected: () => {
@@ -2687,31 +2846,57 @@ export async function main() {
2687
2846
  });
2688
2847
  session.writeState();
2689
2848
  }
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.
2849
+ // Stop/new-session signals remain pending until the Claude SDK confirms the
2850
+ // interrupt. `defer` deliberately makes the poller revisit the same node.
2693
2851
  async function handleRuntimeControlSignal(event) {
2694
2852
  const convoId = event.conversationId;
2695
2853
  const session = sessions.get(convoId);
2696
2854
  if (!session || session.closed)
2697
- return { consume: false };
2855
+ return { defer: true };
2698
2856
  if (event.type === 'new_session') {
2699
2857
  console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
2700
- await resetRuntimeSession(session);
2858
+ try {
2859
+ await resetRuntimeSession(session);
2860
+ }
2861
+ catch (error) {
2862
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] New-session interrupt failed; retrying signal:`, error instanceof Error ? error.message : error);
2863
+ return { defer: true };
2864
+ }
2701
2865
  return;
2702
2866
  }
2703
- const isActive = session.state.state === 'running';
2867
+ const isActive = session.state.state === 'running' || session.state.state === 'requires_action';
2704
2868
  if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
2705
2869
  return; // Nothing to interrupt — consume the stale signal
2706
2870
  }
2707
2871
  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);
2872
+ const sdkTurnActive = isActive && !session.pendingFinalDelivery;
2873
+ session.controlInterruptPending = sdkTurnActive;
2874
+ const interruptResult = await confirmClaudeInterrupt({
2875
+ active: sdkTurnActive,
2876
+ interrupt: () => session.query.interrupt(),
2877
+ });
2878
+ if (interruptResult === 'defer') {
2879
+ session.controlInterruptPending = false;
2880
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Stop interrupt failed; retrying signal`);
2881
+ return { defer: true };
2882
+ }
2883
+ const droppedInputs = event.type === 'stop_and_drop'
2884
+ ? session.pendingInputs.splice(0)
2885
+ : [];
2886
+ if (droppedInputs.length > 0) {
2710
2887
  await markSessionPendingInputsRejected(convoId, droppedInputs);
2711
2888
  }
2889
+ session.markInputCompleted(session.activeInput);
2890
+ for (const input of droppedInputs)
2891
+ session.markInputCompleted(input);
2712
2892
  if (session.activeInput) {
2713
2893
  session.interruptedTurnKeys.add(session.activeInput.turnKey);
2714
2894
  }
2895
+ if (session.finalDeliveryTimer) {
2896
+ clearTimeout(session.finalDeliveryTimer);
2897
+ session.finalDeliveryTimer = null;
2898
+ }
2899
+ session.pendingFinalDelivery = null;
2715
2900
  session.pendingFinalText = null;
2716
2901
  session.turnState = 'interrupted';
2717
2902
  session.clearStreaming().catch(() => { });
@@ -2726,9 +2911,7 @@ export async function main() {
2726
2911
  ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
2727
2912
  completedAt: { '.sv': 'timestamp' },
2728
2913
  }).catch(() => { });
2729
- if (isActive) {
2730
- await session.query.interrupt();
2731
- }
2914
+ session.controlInterruptPending = false;
2732
2915
  }
2733
2916
  controlPoller.start();
2734
2917
  // ── Heartbeat (per-session) ──