@canonmsg/claude-code-plugin 0.24.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, buildPlanApprovalRequest, resolveQuestionAllowOther, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, sendMessageWithRetry, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
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,
@@ -769,7 +739,6 @@ async function buildCanonUserContent(input) {
769
739
  function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
770
740
  const { cwd } = environment;
771
741
  const cardResponseUserId = config.ownerId ?? undefined;
772
- const cardResponseUser = cardResponseUserId ? { responseUserId: cardResponseUserId } : undefined;
773
742
  // Per-session input stream with message queue
774
743
  let resolveInput = null;
775
744
  let inputDone = false;
@@ -785,7 +754,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
785
754
  messageQueue.push(input);
786
755
  }
787
756
  };
788
- 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') => {
789
758
  sendInput(createClaudeInputEnvelope({
790
759
  kind: 'canon',
791
760
  msg,
@@ -793,6 +762,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
793
762
  sourceMessageId,
794
763
  markAccepted,
795
764
  isOwnerTurn,
765
+ requestingUserId,
796
766
  artifactRoutingMode,
797
767
  }));
798
768
  };
@@ -826,6 +796,73 @@ function createSession(conversationId, environment, agentId, client, typingSigna
826
796
  // eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
827
797
  let session;
828
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
+ }
829
866
  function waitForReply(type, requestId) {
830
867
  // Keep session alive while waiting for user input
831
868
  session.lastActivity = Date.now();
@@ -844,30 +881,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
844
881
  };
845
882
  });
846
883
  }
847
- async function waitForRuntimeInputResponse(input) {
848
- session.lastActivity = Date.now();
849
- while (Date.now() < input.expiresAt) {
850
- const response = await client.consumeRuntimeInputResponse({
851
- conversationId,
852
- inputId: input.inputId,
853
- }).catch(() => null);
854
- if (response?.status === 'submitted') {
855
- return { status: 'submitted', answers: response.answers, value: response.value };
856
- }
857
- if (response?.status === 'cancelled' || response?.status === 'timeout') {
858
- return { status: response.status };
859
- }
860
- await sleep(1_000);
861
- }
862
- const response = await client.consumeRuntimeInputResponse({
863
- conversationId,
864
- inputId: input.inputId,
865
- }).catch(() => null);
866
- if (response?.status === 'submitted') {
867
- return { status: 'submitted', answers: response.answers, value: response.value };
868
- }
869
- return { status: 'timeout' };
870
- }
871
884
  function buildArtifactMediaMetadata() {
872
885
  return {
873
886
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
@@ -882,6 +895,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
882
895
  const queryOptions = {
883
896
  systemPrompt: { type: 'preset', preset: 'claude_code' },
884
897
  tools: { type: 'preset', preset: 'claude_code' },
898
+ model: 'default',
885
899
  permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
886
900
  allowDangerouslySkipPermissions: true,
887
901
  cwd,
@@ -897,52 +911,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
897
911
  console.error(`[claude-cli] [${conversationId.slice(0, 8)}] ${trimmed}`);
898
912
  }
899
913
  },
900
- 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) => {
901
924
  if (toolName === 'AskUserQuestion') {
902
- try {
903
- const questionId = randomUUID();
904
- const questions = mapClaudeQuestions(input.questions);
905
- const expiresAt = Date.now() + REPLY_TIMEOUT_MS;
906
- await client.createRuntimeInputRequest({
907
- conversationId,
908
- inputId: questionId,
909
- kind: 'clarify',
910
- expiresAt,
911
- title: 'Claude needs input',
912
- prompt: questions?.length
913
- ? 'Claude needs your input to continue.'
914
- : 'Claude needs input to continue.',
915
- ...(questions ? { questions } : {}),
916
- sensitive: Boolean(questions?.some((question) => question.isSecret)),
917
- ...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
918
- native: {
919
- runtime: 'claude-code',
920
- method: 'AskUserQuestion',
921
- requestId: questionId,
922
- turnId: session.currentTurnId ?? undefined,
923
- },
924
- turnId: session.currentTurnId ?? undefined,
925
- });
926
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
927
- const reply = await waitForRuntimeInputResponse({ inputId: questionId, expiresAt });
928
- if (reply.status !== 'submitted') {
929
- return { behavior: 'deny', message: 'No reply received (timed out)' };
930
- }
931
- return {
932
- behavior: 'allow',
933
- updatedInput: {
934
- ...input,
935
- answers: reply.answers
936
- ? flattenRuntimeInputAnswers(reply.answers)
937
- : reply.value
938
- ? { answer: reply.value }
939
- : {},
940
- },
941
- };
942
- }
943
- catch {
944
- return { behavior: 'deny', message: 'No reply received (timed out)' };
945
- }
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);
946
929
  }
947
930
  if (toolName === 'ExitPlanMode') {
948
931
  try {
@@ -970,22 +953,20 @@ function createSession(conversationId, environment, agentId, client, typingSigna
970
953
  const planBody = typeof input.plan === 'string' && input.plan.trim()
971
954
  ? input.plan.trim()
972
955
  : undefined;
973
- const planApproval = buildPlanApprovalRequest(planId, 'Plan ready for review.', {
956
+ // Route plan-CREATE through the unified spine (`/runtime-plan/request`):
957
+ // seeds the server-owned pending node/attention/state and authors the
958
+ // `plan_approval` card. Resolution is UNCHANGED — the gate still blocks
959
+ // on the server-authored `plan_approval_reply` below.
960
+ await client.createRuntimePlanRequest({
961
+ conversationId,
962
+ planId,
963
+ title: 'Plan ready for review.',
974
964
  summary: 'Claude has prepared a plan and is asking permission to continue.',
975
965
  ...(planBody ? { body: planBody } : {}),
976
- ...(cardResponseUser ?? {}),
966
+ ...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
977
967
  ...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
978
- });
979
- await sendMessageWithRetry(client, conversationId, planApproval.text, {
980
- messageId: `claude-plan-${planId}`,
981
- ...(session.activeSelfContextId
982
- ? { selfContextId: session.activeSelfContextId }
983
- : {}),
984
- metadata: {
985
- ...planApproval.metadata,
986
- turnSemantics: 'control',
987
- replyBehavior: 'suppress_auto_reply',
988
- },
968
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
969
+ expiresAt: Date.now() + REPLY_TIMEOUT_MS,
989
970
  });
990
971
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
991
972
  const reply = await waitForReply('plan_approval_reply', planId);
@@ -1094,7 +1075,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1094
1075
  environment,
1095
1076
  query: q,
1096
1077
  sendInput,
1097
- 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),
1098
1079
  state: {
1099
1080
  model: undefined,
1100
1081
  permissionMode: config.permissionMode,
@@ -1124,12 +1105,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1124
1105
  lastAcceptedIntent: null,
1125
1106
  lastActivity: Date.now(),
1126
1107
  typingKeepaliveTimer: null,
1108
+ controlInterruptPending: false,
1127
1109
  closed: false,
1128
1110
  sdkSessionId: resumeSessionId || null,
1129
1111
  pendingReply: null,
1130
1112
  writeState: () => writeState(),
1131
1113
  writeTurn: () => writeTurn(),
1132
1114
  clearStreaming: () => clearStreaming(),
1115
+ markInputCompleted: (input) => markInputCompleted(input),
1133
1116
  setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
1134
1117
  clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
1135
1118
  };
@@ -1174,6 +1157,24 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1174
1157
  function clearStreaming() {
1175
1158
  return streamingOutput.clear().catch(() => { });
1176
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
+ }
1177
1178
  function getFinalTurnTrail() {
1178
1179
  const turnId = session.currentTurnId ?? `claude-${conversationId}`;
1179
1180
  return streamingOutput.getFinalTrail().map((block) => ({ ...block, turnId }));
@@ -1267,6 +1268,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1267
1268
  },
1268
1269
  });
1269
1270
  session.finalizedTurnKeys.add(deliverTurn.turnKey);
1271
+ markInputCompleted(deliverTurn);
1270
1272
  session.state.state = 'idle';
1271
1273
  session.pendingFinalDelivery = null;
1272
1274
  clearFinalDeliveryTimer();
@@ -1479,7 +1481,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1479
1481
  sendInput(input);
1480
1482
  }
1481
1483
  function drainPendingInput() {
1482
- 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)
1483
1488
  return;
1484
1489
  const next = session.pendingInputs.shift();
1485
1490
  if (!next)
@@ -1488,7 +1493,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1488
1493
  writeTurn();
1489
1494
  void startCanonInput(next);
1490
1495
  }
1491
- 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') => {
1492
1497
  session.lastActivity = Date.now();
1493
1498
  const input = createClaudeInputEnvelope({
1494
1499
  kind: 'canon',
@@ -1497,6 +1502,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1497
1502
  sourceMessageId,
1498
1503
  markAccepted,
1499
1504
  isOwnerTurn,
1505
+ requestingUserId,
1500
1506
  artifactRoutingMode,
1501
1507
  });
1502
1508
  if (session.pendingFinalDelivery) {
@@ -1510,15 +1516,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1510
1516
  return;
1511
1517
  }
1512
1518
  if (session.state.state === 'running' && intent === 'interrupt') {
1513
- if (session.activeInput) {
1514
- session.interruptedTurnKeys.add(session.activeInput.turnKey);
1515
- }
1516
1519
  session.pendingInputs.unshift(input);
1517
- session.turnState = 'interrupted';
1518
- stopVisibleWorkSignal();
1519
- clearStreaming().catch(() => { });
1520
1520
  writeTurn();
1521
- 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
+ });
1522
1534
  return;
1523
1535
  }
1524
1536
  session.lastAcceptedIntent = intent;
@@ -1665,6 +1677,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1665
1677
  // Non-fatal
1666
1678
  }
1667
1679
  if (finalDelivered) {
1680
+ if (!finalText || !shouldDeliverFinal) {
1681
+ markInputCompleted(completedInput);
1682
+ }
1668
1683
  if (completedInput?.turnKey === session.activeInput?.turnKey) {
1669
1684
  session.activeInput = null;
1670
1685
  }
@@ -1743,9 +1758,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1743
1758
  // Refresh the canonical runtime descriptor after Claude reports supported models.
1744
1759
  try {
1745
1760
  const models = await q.supportedModels();
1746
- const modelList = mergeClaudeModelOptions({
1747
- previous: runtimeModels,
1748
- next: toModelOptions(models),
1761
+ const supplemental = await detectSupplementalRuntimeModels(cwd);
1762
+ const modelList = resolveClaudeModelOptions({
1763
+ discovered: mergeClaudeDiscoveredModelOptions(toModelOptions(models), ...supplemental),
1749
1764
  selectedModel: session.state.model,
1750
1765
  });
1751
1766
  session.availableModels = modelList;
@@ -2062,6 +2077,10 @@ export async function main() {
2062
2077
  // ── Session manager ──
2063
2078
  const sessions = new Map();
2064
2079
  const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
2080
+ // Interactive runtime input (AskUserQuestion) works with or without an owner,
2081
+ // so its poll engine is always present. Create + poll fold onto the built-in
2082
+ // input descriptor via `request()` (see the AskUserQuestion canUseTool branch).
2083
+ const runtimeInputManager = new RuntimeRequestManager(client, { agentId, ownerId: ownerId ?? '' });
2065
2084
  const pendingSessionCreations = new Map();
2066
2085
  // Shared /control channel poller (claude host profile): session + signal
2067
2086
  // keys, sequential conversations, active/idle cadence + jitter sampled at
@@ -2209,25 +2228,13 @@ export async function main() {
2209
2228
  });
2210
2229
  }
2211
2230
  else {
2212
- const existingState = loadRuntimeSessionState(runtimeId, {
2213
- conversationId,
2214
- baseCwd: session.environment.baseCwd,
2215
- executionMode: session.environment.mode,
2216
- });
2217
2231
  savedSessionIds.delete(resumeKey);
2218
2232
  clearRuntimeSessionState(runtimeId, {
2219
2233
  conversationId,
2220
2234
  baseCwd: session.environment.baseCwd,
2221
2235
  executionMode: session.environment.mode,
2236
+ preserveInboundCursor: true,
2222
2237
  });
2223
- if (existingState?.lastInboundMessageId) {
2224
- saveRuntimeSessionState(runtimeId, {
2225
- conversationId,
2226
- baseCwd: session.environment.baseCwd,
2227
- executionMode: session.environment.mode,
2228
- lastInboundMessageId: existingState.lastInboundMessageId,
2229
- });
2230
- }
2231
2238
  }
2232
2239
  session.clearStreaming().catch(() => { });
2233
2240
  if (session.idleResetTimer) {
@@ -2251,8 +2258,27 @@ export async function main() {
2251
2258
  }
2252
2259
  async function resetRuntimeSession(session) {
2253
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
+ }
2254
2277
  const droppedInputs = session.pendingInputs.splice(0);
2255
2278
  await markSessionPendingInputsRejected(conversationId, droppedInputs);
2279
+ session.markInputCompleted(session.activeInput);
2280
+ for (const input of droppedInputs)
2281
+ session.markInputCompleted(input);
2256
2282
  if (session.activeInput) {
2257
2283
  session.interruptedTurnKeys.add(session.activeInput.turnKey);
2258
2284
  }
@@ -2261,9 +2287,6 @@ export async function main() {
2261
2287
  session.turnState = 'interrupted';
2262
2288
  session.clearStreaming().catch(() => { });
2263
2289
  typingSignals.clear(conversationId).catch(() => { });
2264
- if (session.state.state === 'running') {
2265
- await session.query.interrupt().catch(() => { });
2266
- }
2267
2290
  closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
2268
2291
  }
2269
2292
  async function markSessionPendingInputsRejected(conversationId, inputs) {
@@ -2347,10 +2370,17 @@ export async function main() {
2347
2370
  conversationId: convoId,
2348
2371
  baseCwd: existing.environment.baseCwd,
2349
2372
  executionMode: existing.environment.mode,
2373
+ preserveInboundCursor: true,
2350
2374
  });
2351
2375
  }
2352
2376
  }
2353
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
+ }
2354
2384
  typingSignals.clear(convoId).catch(() => { });
2355
2385
  typingSignals.dispose(convoId);
2356
2386
  sessions.delete(convoId);
@@ -2368,9 +2398,14 @@ export async function main() {
2368
2398
  availableExecutionModes: hostAvailableExecutionModes,
2369
2399
  runtimePresentation,
2370
2400
  approvalManager,
2401
+ runtimeInputManager,
2371
2402
  canRequestApproval: () => canRequestCanonApproval(conversationId),
2372
2403
  runtimeId,
2404
+ recoveryBaseCwd: workingDir,
2373
2405
  ownerId,
2406
+ onInputCompleted: (sourceMessageId) => {
2407
+ settleInboundMessageId(sourceMessageId, true);
2408
+ },
2374
2409
  publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
2375
2410
  }, resumeId);
2376
2411
  sessions.set(conversationId, session);
@@ -2391,9 +2426,27 @@ export async function main() {
2391
2426
  }
2392
2427
  }
2393
2428
  async function enqueueRecoveredInboundMessage(input) {
2394
- 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
+ };
2395
2448
  if (m.senderId === agentId)
2396
- return;
2449
+ return 'handled';
2397
2450
  let materialized = [];
2398
2451
  if (m.id) {
2399
2452
  try {
@@ -2430,7 +2483,7 @@ export async function main() {
2430
2483
  const autoReply = decideAutoReply(participantContext, behavior);
2431
2484
  if (!autoReply.allow) {
2432
2485
  console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Suppressed recovered auto-reply: ${autoReply.reason}`);
2433
- return;
2486
+ return 'handled';
2434
2487
  }
2435
2488
  markGroupContextModeUsed(input.conversationId, participantContext.groupContextMode);
2436
2489
  const turnMetadata = normalizeTurnMetadata(m.metadata);
@@ -2453,7 +2506,7 @@ export async function main() {
2453
2506
  replyBehavior: 'suppress_auto_reply',
2454
2507
  },
2455
2508
  }).catch(() => { });
2456
- return;
2509
+ return 'handled';
2457
2510
  }
2458
2511
  session.activeSelfContextId = activeSelfContextId;
2459
2512
  const promptText = buildCanonPrompt({
@@ -2479,15 +2532,154 @@ export async function main() {
2479
2532
  content: messageContent,
2480
2533
  },
2481
2534
  parent_tool_use_id: null,
2482
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
2483
- if (m.id) {
2484
- saveRuntimeSessionState(runtimeId, {
2485
- conversationId: input.conversationId,
2486
- baseCwd: workingDir,
2487
- lastInboundMessageId: m.id,
2488
- });
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;
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;
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
+ }
2489
2671
  }
2490
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();
2491
2683
  // ── Connect SSE stream for inbound Canon messages ──
2492
2684
  const stream = new CanonStream({
2493
2685
  apiKey,
@@ -2497,53 +2689,19 @@ export async function main() {
2497
2689
  const m = payload.message;
2498
2690
  if (m.senderId === agentId)
2499
2691
  return;
2692
+ if (!claimInboundMessageId(m.id))
2693
+ return;
2500
2694
  const convoId = payload.conversationId;
2501
2695
  knownConversationIds.add(convoId);
2502
- if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
2503
- const consumed = approvalManager?.handleMessage(convoId, {
2504
- senderId: m.senderId,
2505
- metadata: m.metadata,
2506
- }) ?? false;
2507
- if (m.metadata?.type === 'approval_reply') {
2508
- console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
2509
- return;
2510
- }
2511
- if (consumed) {
2512
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
2513
- return;
2514
- }
2515
- }
2516
- // Check for metadata replies (plan approvals).
2517
- if (m.metadata?.type === 'plan_approval_reply') {
2518
- const existingSession = sessions.get(convoId);
2519
- const pendingReply = existingSession?.pendingReply;
2520
- const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2521
- const replyRequestId = m.metadata.planId;
2522
- if (!pendingReply) {
2523
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
2524
- return;
2525
- }
2526
- if (!isOwnerReply) {
2527
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
2528
- return;
2529
- }
2530
- if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
2531
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
2532
- return;
2533
- }
2534
- if (existingSession?.pendingReply) {
2535
- const { resolve } = existingSession.pendingReply;
2536
- existingSession.pendingReply = null;
2537
- existingSession.lastActivity = Date.now();
2538
- resolve(m.metadata);
2539
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
2540
- return;
2541
- }
2696
+ if (handleRuntimeReplyMessage(convoId, m)) {
2697
+ settleInboundMessageId(m.id, true);
2698
+ return;
2542
2699
  }
2543
2700
  const sender = m.senderName || m.senderId;
2544
2701
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2545
2702
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2546
2703
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2704
+ settleInboundMessageId(m.id, true);
2547
2705
  return;
2548
2706
  }
2549
2707
  void (async () => {
@@ -2588,7 +2746,7 @@ export async function main() {
2588
2746
  : decideAutoReply(participantContext, behavior);
2589
2747
  if (!autoReply.allow) {
2590
2748
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
2591
- return;
2749
+ return false;
2592
2750
  }
2593
2751
  markGroupContextModeUsed(convoId, participantContext.groupContextMode);
2594
2752
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
@@ -2612,7 +2770,7 @@ export async function main() {
2612
2770
  replyBehavior: 'suppress_auto_reply',
2613
2771
  },
2614
2772
  }).catch(() => { });
2615
- return;
2773
+ return false;
2616
2774
  }
2617
2775
  session.activeSelfContextId = activeSelfContextId;
2618
2776
  const promptText = buildCanonPrompt({
@@ -2638,15 +2796,15 @@ export async function main() {
2638
2796
  content: messageContent,
2639
2797
  },
2640
2798
  parent_tool_use_id: null,
2641
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
2642
- if (m.id) {
2643
- saveRuntimeSessionState(runtimeId, {
2644
- conversationId: convoId,
2645
- baseCwd: workingDir,
2646
- lastInboundMessageId: m.id,
2647
- });
2648
- }
2649
- })();
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
+ });
2650
2808
  },
2651
2809
  onMessageDeleted: (payload) => {
2652
2810
  removeQueuedInput(payload.conversationId, payload.messageId);
@@ -2657,6 +2815,7 @@ export async function main() {
2657
2815
  onConnected: () => {
2658
2816
  streamConnected = true;
2659
2817
  void publishRuntimeHeartbeat();
2818
+ void recoverMissedInboundMessages();
2660
2819
  console.error('[canon-host] SSE connected');
2661
2820
  },
2662
2821
  onDisconnected: () => {
@@ -2703,31 +2862,57 @@ export async function main() {
2703
2862
  });
2704
2863
  session.writeState();
2705
2864
  }
2706
- // Interrupt/stop-clear signal. Throwing leaves the node in place
2707
- // (consumeOnError: false); lastSeen has already advanced, so the failed
2708
- // signal is reported but never retried.
2865
+ // Stop/new-session signals remain pending until the Claude SDK confirms the
2866
+ // interrupt. `defer` deliberately makes the poller revisit the same node.
2709
2867
  async function handleRuntimeControlSignal(event) {
2710
2868
  const convoId = event.conversationId;
2711
2869
  const session = sessions.get(convoId);
2712
2870
  if (!session || session.closed)
2713
- return { consume: false };
2871
+ return { defer: true };
2714
2872
  if (event.type === 'new_session') {
2715
2873
  console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
2716
- 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
+ }
2717
2881
  return;
2718
2882
  }
2719
- const isActive = session.state.state === 'running';
2883
+ const isActive = session.state.state === 'running' || session.state.state === 'requires_action';
2720
2884
  if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
2721
2885
  return; // Nothing to interrupt — consume the stale signal
2722
2886
  }
2723
2887
  console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
2724
- if (event.type === 'stop_and_drop') {
2725
- const droppedInputs = session.pendingInputs.splice(0);
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) {
2726
2903
  await markSessionPendingInputsRejected(convoId, droppedInputs);
2727
2904
  }
2905
+ session.markInputCompleted(session.activeInput);
2906
+ for (const input of droppedInputs)
2907
+ session.markInputCompleted(input);
2728
2908
  if (session.activeInput) {
2729
2909
  session.interruptedTurnKeys.add(session.activeInput.turnKey);
2730
2910
  }
2911
+ if (session.finalDeliveryTimer) {
2912
+ clearTimeout(session.finalDeliveryTimer);
2913
+ session.finalDeliveryTimer = null;
2914
+ }
2915
+ session.pendingFinalDelivery = null;
2731
2916
  session.pendingFinalText = null;
2732
2917
  session.turnState = 'interrupted';
2733
2918
  session.clearStreaming().catch(() => { });
@@ -2742,9 +2927,7 @@ export async function main() {
2742
2927
  ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
2743
2928
  completedAt: { '.sv': 'timestamp' },
2744
2929
  }).catch(() => { });
2745
- if (isActive) {
2746
- await session.query.interrupt();
2747
- }
2930
+ session.controlInterruptPending = false;
2748
2931
  }
2749
2932
  controlPoller.start();
2750
2933
  // ── Heartbeat (per-session) ──
@@ -2778,6 +2961,7 @@ export async function main() {
2778
2961
  clearInterval(idleCheck);
2779
2962
  stream.stop();
2780
2963
  approvalManager?.dispose();
2964
+ runtimeInputManager.dispose();
2781
2965
  await runtimeState.clearAgentRuntime().catch(() => { });
2782
2966
  for (const convoId of [...sessions.keys()]) {
2783
2967
  closeSession(convoId);