@canonmsg/claude-code-plugin 0.36.1 → 0.36.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "Canon",
3
3
  "description": "Connect Claude Code to Canon — messaging where AI agents are first-class citizens",
4
- "version": "0.36.1",
4
+ "version": "0.36.3",
5
5
  "channels": [
6
6
  {
7
7
  "server": "canon-channel",
package/README.md CHANGED
@@ -4,7 +4,7 @@ Connect Claude Code to [Canon](https://canonmail.com/agents) — a messaging app
4
4
 
5
5
  ## Quick start
6
6
 
7
- The package includes a compatible Claude Code runtime. If `claude` is installed on `PATH`, or selected with `CANON_CLAUDE_CLI_PATH`, use Claude Code 2.1.220 or newer — the model picker lists whatever the CLI reports, so an older binary hides newer model families. Either source below that minimum is logged and skipped in favour of the bundled runtime, so a stale install costs model options rather than taking the host down.
7
+ The package includes a compatible Claude Code runtime. If `claude` is installed on `PATH`, or selected with `CANON_CLAUDE_CLI_PATH`, use Claude Code 2.1.278 or newer. The bundled Claude Agent SDK is pinned to 0.3.278; Canon relies on its explicit input/result attribution for merged messages and native errors. An external binary below that minimum is logged and skipped in favour of the bundled runtime.
8
8
 
9
9
  ```bash
10
10
  # Install
package/dist/host.js CHANGED
@@ -30,12 +30,12 @@ import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMes
30
30
  import { captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, createRecoveryCheckpointTracker, createReconnectRecoveryCoordinator, } from '@canonmsg/coding-agent-host';
31
31
  import { buildCanonUserContent, renderClaudeInboundContent, withClaudeReplyContextMediaReferences, } from './canon-user-content.js';
32
32
  import { buildClaudeNativeCommandInput, createClaudeCommandContextHook, getClaudeNativeCommandOutput, } from './native-command-input.js';
33
- import { EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonGroupContext, buildCompactGroupContextLines, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, hydrateCanonReplyContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, parseTurnVerbosityConfig, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, isPendingCanonOperation, sendMessageWithRetry, sendMessageWithRetryChunked, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, resolveLocalRuntimeSessionState, saveRuntimeSessionState, clearRuntimeSessionState, publishHostSessionSnapshots, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, resolveTurnVerbosity, upsertLocalRuntimeEntry, } from '@canonmsg/core';
33
+ import { EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonGroupContext, buildCompactGroupContextLines, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, hydrateCanonReplyContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, parseTurnVerbosityConfig, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, isPendingCanonOperation, sendMessageWithRetry, sendMessageWithRetryChunked, markLocalRuntimeStopped, releaseConversationEnvironment, resolveLocalRuntimeSessionState, saveRuntimeSessionState, clearRuntimeSessionState, publishHostSessionSnapshots, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, resolveTurnVerbosity, upsertLocalRuntimeEntry, } from '@canonmsg/core';
34
34
  import { runCli } from '@canonmsg/core';
35
35
  import { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer } from '@canonmsg/agent-tools';
36
36
  import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
37
37
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
38
- import { boundClaudeFinalMetadata, buildClaudeFinalChunkingOptions, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudePendingFinalDelivery, buildClaudeTurnFailureNotice, buildTruncatedFinalText, buildUndeliverableFinalNotice, canDrainClaudeQueuedInput, beginClaudeAssistantResponse, claimClaudeTextSegmentId, claudeFinalWillChunk, classifyFinalDeliveryFailure, claudeInputOwnsTurnSlot, createClaudeTurnActivityState, decideClaudeControlSignalAction, decideClaudeInboundDispatch, dispatchClaudeInput, endClaudeAssistantResponse, isClaudeTurnSlotReserved, readClaudeFinalDeliveryResume, releaseClaudeTurnSlot, runClaudeExhaustedFinalDelivery, shouldApplyClaudeEchoedSessionState, shouldReleaseClaudeTurnSlot, isClaudeMainTurnMessage, openClaudeTurn, planClaudeAssistantTrail, planClaudeStreamingWrite, planClaudeToolCallStart, planClaudeToolProgress, planClaudeToolResults, claudeFinalTurnTrail, publishedClaudeTurnState, rememberDispatchedClaudeInput, resetClaudeTurnActivityState, shouldOpenClaudeTurnOnRunning, shouldStopTypingDotsOnStreamedText, isOpenClaudeTurnState, takeClaudeResultOwner, takeClaudeToolBlockIdByIndex, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeTurnFinal, describeUndeliveredClaudeFinal, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, shouldRouteClaudeTurnArtifacts, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
38
+ import { boundClaudeFinalMetadata, buildClaudeFinalChunkingOptions, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudePendingFinalDelivery, buildClaudeTurnFailureNotice, buildTruncatedFinalText, buildUndeliverableFinalNotice, canDrainClaudeQueuedInput, beginClaudeAssistantResponse, claimClaudeTextSegmentId, claudeFinalWillChunk, classifyFinalDeliveryFailure, claudeInputOwnsTurnSlot, createClaudeTurnActivityState, decideClaudeControlSignalAction, decideClaudeInboundDispatch, dispatchClaudeInput, endClaudeAssistantResponse, isClaudeTurnSlotReserved, readClaudeFinalDeliveryResume, releaseClaudeTurnSlot, runClaudeExhaustedFinalDelivery, shouldApplyClaudeEchoedSessionState, shouldReleaseClaudeTurnSlot, isClaudeMainTurnMessage, openClaudeTurn, planClaudeAssistantTrail, planClaudeStreamingWrite, planClaudeToolCallStart, planClaudeToolProgress, planClaudeToolResults, claudeFinalTurnTrail, publishedClaudeTurnState, rememberDispatchedClaudeInput, resetClaudeTurnActivityState, shouldOpenClaudeTurnOnRunning, shouldStopTypingDotsOnStreamedText, isOpenClaudeTurnState, takeClaudeResultOwner, claudeCompletedSourceMessageIds, shouldFailClaudeSessionForUnattributedResult, takeClaudeToolBlockIdByIndex, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeTurnFinal, describeUndeliveredClaudeFinal, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, shouldRouteClaudeTurnArtifacts, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
39
39
  import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } from './user-dialog.js';
40
40
  import { isProactiveCommunicationEnabled } from './communication-policy.js';
41
41
  function parseRuntimeVisibilityPreset(value) {
@@ -1059,9 +1059,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1059
1059
  return streamingOutput.clear().catch(() => { });
1060
1060
  }
1061
1061
  function markInputCompleted(input) {
1062
- if (input?.kind !== 'canon' || !input.sourceMessageId)
1063
- return;
1064
- config.onInputCompleted?.(input.sourceMessageId);
1062
+ for (const sourceMessageId of claudeCompletedSourceMessageIds(input)) {
1063
+ config.onInputCompleted?.(sourceMessageId);
1064
+ }
1065
1065
  }
1066
1066
  /**
1067
1067
  * The turn trail to attach to the final send. Empty on a quiet turn — see
@@ -1120,6 +1120,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1120
1120
  drainPendingInput();
1121
1121
  }
1122
1122
  function scheduleFinalHandoffReset() {
1123
+ if (session.closed)
1124
+ return;
1123
1125
  clearIdleResetTimer();
1124
1126
  session.idleResetTimer = setTimeout(() => {
1125
1127
  session.idleResetTimer = null;
@@ -1177,8 +1179,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1177
1179
  session.finalizedTurnKeys.add(turn.turnKey);
1178
1180
  markInputCompleted(turn);
1179
1181
  releaseTurnSlotFor(turn);
1180
- session.pendingFinalDelivery = null;
1181
- clearFinalDeliveryTimer();
1182
+ if (session.pendingFinalDelivery?.turnKey === turn.turnKey) {
1183
+ session.pendingFinalDelivery = null;
1184
+ clearFinalDeliveryTimer();
1185
+ }
1182
1186
  writeState();
1183
1187
  }
1184
1188
  /**
@@ -1461,6 +1465,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1461
1465
  }, FINAL_DELIVERY_RETRY_MS);
1462
1466
  }
1463
1467
  function writeState() {
1468
+ if (session.closed)
1469
+ return;
1464
1470
  runtimeState.writeSessionState(conversationId, {
1465
1471
  contextUsage: session.state.contextUsage,
1466
1472
  hostMode: true,
@@ -1487,6 +1493,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1487
1493
  writeState();
1488
1494
  }
1489
1495
  function writeTurn() {
1496
+ if (session.closed)
1497
+ return;
1490
1498
  const isOpenTurn = isOpenClaudeTurnState(session.turnState);
1491
1499
  if (isOpenTurn) {
1492
1500
  session.currentTurnUpdatedAt = Date.now();
@@ -1635,16 +1643,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1635
1643
  .updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected')
1636
1644
  .catch(() => { });
1637
1645
  }
1638
- if (input.kind === 'canon' && input.sourceMessageId) {
1639
- const id = `message:${conversationId}:${input.sourceMessageId}`;
1640
- void client.getEndpoint().then(async (engine) => {
1641
- const entry = await engine.getInbound(id);
1642
- if (entry && ['observed', 'offered', 'deferred'].includes(entry.state)) {
1643
- await engine.setInboundState(id, 'deferred', 'native-preflight');
1644
- config.onInputDeferred?.(input.sourceMessageId);
1645
- }
1646
- }).catch((error) => console.error('[canon-host] Failed to defer native input:', error));
1647
- }
1646
+ void input.inboundHandoff?.release('native-preflight').catch((error) => {
1647
+ console.error('[canon-host] Failed to release native input:', error);
1648
+ });
1648
1649
  // resetTurnToIdle releases the slot, tears the published turn down (typing
1649
1650
  // signal, seeded /streaming, turn state) and drains the queue. On a closed
1650
1651
  // session it is a no-op, which is correct: the teardown owns that path.
@@ -1654,17 +1655,26 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1654
1655
  return dispatchClaudeInput(session, input, {
1655
1656
  openTurn,
1656
1657
  markAccepted: (dispatched) => markQueuedMessageAccepted(dispatched.sourceMessageId, dispatched.markAccepted),
1657
- prepareArtifacts: async (input) => {
1658
- await prepareArtifactsForInput(input);
1659
- if (input.kind === 'canon' && input.sourceMessageId) {
1660
- const engine = await client.getEndpoint();
1661
- const id = `message:${conversationId}:${input.sourceMessageId}`;
1662
- if (!await engine.claimInbound(id)) {
1663
- throw new Error('Canon input already has an execution owner.');
1664
- }
1658
+ prepareArtifacts: prepareArtifactsForInput,
1659
+ send: async (input) => {
1660
+ if (input.kind !== 'canon' || !input.sourceMessageId) {
1661
+ sendInput(input);
1662
+ return;
1665
1663
  }
1664
+ const engine = await client.getEndpoint();
1665
+ input.inboundHandoff ??= await engine.acquireInbound(`message:${conversationId}:${input.sourceMessageId}`) ?? undefined;
1666
+ if (!input.inboundHandoff)
1667
+ throw new Error('Canon input already has an execution owner.');
1668
+ if (session.closed)
1669
+ await input.inboundHandoff.release('session-closed');
1670
+ const started = await engine.startInbound(input.inboundHandoff, () => {
1671
+ if (session.dispatchingInput === input)
1672
+ session.dispatchingInput = null;
1673
+ sendInput(input);
1674
+ });
1675
+ if (started.status !== 'submitted')
1676
+ throw new Error(`Canon input ${started.status} before native dispatch.`);
1666
1677
  },
1667
- send: sendInput,
1668
1678
  abandon: abandonDispatchedInput,
1669
1679
  });
1670
1680
  }
@@ -1780,6 +1790,29 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1780
1790
  applyTrailCommands(planClaudeToolResults(session.turnActivity, content));
1781
1791
  }
1782
1792
  // ── Process SDK output messages ──
1793
+ function finishSession() {
1794
+ if (session.closed)
1795
+ return;
1796
+ session.closed = true;
1797
+ inputDone = true;
1798
+ clearIdleResetTimer();
1799
+ clearFinalDeliveryTimer();
1800
+ stopVisibleWorkSignal();
1801
+ try {
1802
+ q.close();
1803
+ }
1804
+ catch { }
1805
+ // A native failure cannot prove whether dispatched work ran. Release local
1806
+ // in-flight claims without completing endpoint receipts or replaying work.
1807
+ const unfinished = new Set([
1808
+ session.activeInput, session.dispatchingInput,
1809
+ ...session.pendingInputs, ...dispatchedInputs.values(),
1810
+ ]);
1811
+ for (const input of unfinished)
1812
+ void input?.inboundHandoff?.release('session-ended').catch(console.error);
1813
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ended`);
1814
+ onSessionEnd(conversationId, session.sdkSessionId);
1815
+ }
1783
1816
  const processMessages = async () => {
1784
1817
  try {
1785
1818
  for await (const msg of q) {
@@ -1988,10 +2021,17 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1988
2021
  const resultSessionId = msg.session_id;
1989
2022
  if (resultSessionId)
1990
2023
  session.sdkSessionId = resultSessionId;
1991
- // Match the result to the envelope that produced it. Falling back to
1992
- // the slot only when the SDK reports no uuid keeps older runtimes
1993
- // working, at their existing accuracy.
1994
- const completedInput = takeClaudeResultOwner(dispatchedInputs, msg.user_message_uuid) ?? session.activeInput;
2024
+ // A result may acknowledge several coalesced inputs, but never a
2025
+ // queued input absent from its UUID list. Native background/resume
2026
+ // results with no known UUID do not belong to the active Canon turn.
2027
+ const completedInput = takeClaudeResultOwner(dispatchedInputs, msg.user_message_uuid, msg.user_message_uuids);
2028
+ if (!completedInput) {
2029
+ if (shouldFailClaudeSessionForUnattributedResult(msg, Boolean(session.activeInput || session.dispatchingInput || session.pendingInputs.length))) {
2030
+ throw new Error('Claude returned an unattributed result; native session closed with unfinished inputs preserved.');
2031
+ }
2032
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring result without a known input UUID`);
2033
+ break;
2034
+ }
1995
2035
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
1996
2036
  const resultText = typeof msg.result === 'string'
1997
2037
  ? msg.result.trim()
@@ -2014,7 +2054,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2014
2054
  // the artifacts still POST first, ahead of the reply they belong to.
2015
2055
  const turnFinal = composeClaudeTurnFinal({
2016
2056
  resultText,
2017
- streamedText: session.pendingFinalText,
2057
+ streamedText: completedInput.turnKey === session.activeInput?.turnKey
2058
+ ? session.pendingFinalText : null,
2018
2059
  failureNotice,
2019
2060
  silenced,
2020
2061
  });
@@ -2077,6 +2118,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2077
2118
  if (!finalText || !shouldDeliverFinal) {
2078
2119
  markInputCompleted(completedInput);
2079
2120
  }
2121
+ // Sending the final and fetching usage both await. A newer turn
2122
+ // may now own the live text, pending delivery and handoff timer.
2123
+ if (!shouldReleaseClaudeTurnSlot({
2124
+ dispatching: session.dispatchingInput !== null,
2125
+ activeTurnKey: session.activeInput?.turnKey ?? null,
2126
+ completedTurnKey: completedInput.turnKey,
2127
+ }))
2128
+ break;
2080
2129
  if (completedInput?.turnKey === session.activeInput?.turnKey) {
2081
2130
  session.activeInput = null;
2082
2131
  }
@@ -2118,16 +2167,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2118
2167
  catch (err) {
2119
2168
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Query stream error:`, err);
2120
2169
  }
2121
- // Only call onSessionEnd if not already closed (avoids double cleanup from closeSession)
2122
- if (!session.closed) {
2123
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ended`);
2124
- onSessionEnd(conversationId, session.sdkSessionId);
2125
- }
2170
+ finishSession();
2126
2171
  };
2127
2172
  // Start processing loop and initialize session
2128
2173
  const init = async () => {
2129
2174
  processMessages().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] processMessages crashed:`, err));
2130
2175
  await q.initializationResult();
2176
+ if (session.closed)
2177
+ return;
2131
2178
  if (config.model) {
2132
2179
  try {
2133
2180
  await q.setModel(config.model);
@@ -2160,6 +2207,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2160
2207
  const models = await q.supportedModels();
2161
2208
  const discoveredOptions = toModelOptions(models);
2162
2209
  const supplemental = await detectSupplementalRuntimeModels(cwd, deriveClaudeSupplementalModelProbes(discoveredOptions));
2210
+ if (session.closed)
2211
+ return;
2163
2212
  const modelList = resolveClaudeModelOptions({
2164
2213
  discovered: mergeClaudeDiscoveredModelOptions(discoveredOptions, ...supplemental),
2165
2214
  selectedModel: session.state.model,
@@ -2194,10 +2243,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2194
2243
  };
2195
2244
  init().catch((err) => {
2196
2245
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Init failed:`, err);
2197
- if (resumeSessionId) {
2246
+ if (resumeSessionId)
2198
2247
  session.sdkSessionId = null;
2199
- onSessionEnd(conversationId, null);
2200
- }
2248
+ finishSession();
2201
2249
  });
2202
2250
  return session;
2203
2251
  }
@@ -2642,6 +2690,9 @@ export async function main() {
2642
2690
  return;
2643
2691
  const saveResume = options.saveResume !== false;
2644
2692
  session.closed = true;
2693
+ for (const input of [session.dispatchingInput, ...session.pendingInputs]) {
2694
+ void input?.inboundHandoff?.release('session-closed').catch(console.error);
2695
+ }
2645
2696
  if (session.typingKeepaliveTimer) {
2646
2697
  clearInterval(session.typingKeepaliveTimer);
2647
2698
  session.typingKeepaliveTimer = null;
@@ -2687,6 +2738,11 @@ export async function main() {
2687
2738
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${options.reason ?? (saveResume ? 'suspended (sessionId saved for resume)' : 'cleared')}`);
2688
2739
  }
2689
2740
  async function resetRuntimeSession(session) {
2741
+ const dispatching = session.dispatchingInput;
2742
+ void dispatching?.inboundHandoff?.cancel('canceled-before-execution').then((canceled) => {
2743
+ if (canceled)
2744
+ session.markInputCompleted(dispatching);
2745
+ }).catch(console.error);
2690
2746
  const conversationId = session.conversationId;
2691
2747
  // `dispatchingInput` excludes the dispatch window: the slot is reserved but
2692
2748
  // the SDK has nothing in flight, so there is nothing to interrupt. The
@@ -2711,8 +2767,10 @@ export async function main() {
2711
2767
  const droppedInputs = session.pendingInputs.splice(0);
2712
2768
  await markSessionPendingInputsRejected(conversationId, droppedInputs);
2713
2769
  session.markInputCompleted(session.activeInput);
2714
- for (const input of droppedInputs)
2715
- session.markInputCompleted(input);
2770
+ for (const input of droppedInputs) {
2771
+ if (!input.inboundHandoff || input.inboundHandoff.phase === 'settled')
2772
+ session.markInputCompleted(input);
2773
+ }
2716
2774
  if (session.activeInput) {
2717
2775
  session.interruptedTurnKeys.add(session.activeInput.turnKey);
2718
2776
  }
@@ -2724,13 +2782,14 @@ export async function main() {
2724
2782
  closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
2725
2783
  }
2726
2784
  async function markSessionPendingInputsRejected(conversationId, inputs) {
2727
- await Promise.all(inputs.map((input) => {
2785
+ await Promise.all(inputs.map(async (input) => {
2786
+ await input.inboundHandoff?.cancel('canceled-before-execution');
2728
2787
  if (!input.markAccepted || !input.sourceMessageId)
2729
2788
  return Promise.resolve();
2730
2789
  return client.updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected').catch(() => { });
2731
2790
  }));
2732
2791
  }
2733
- function removeQueuedInput(conversationId, sourceMessageId) {
2792
+ async function removeQueuedInput(conversationId, sourceMessageId) {
2734
2793
  const session = sessions.get(conversationId);
2735
2794
  if (!session || session.pendingInputs.length === 0)
2736
2795
  return;
@@ -2738,8 +2797,10 @@ export async function main() {
2738
2797
  if (removed.length === 0)
2739
2798
  return;
2740
2799
  session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
2741
- for (const input of removed)
2742
- session.markInputCompleted(input);
2800
+ await Promise.all(removed.map(async (input) => {
2801
+ if (!input.inboundHandoff || await input.inboundHandoff.cancel('message-deleted'))
2802
+ session.markInputCompleted(input);
2803
+ }));
2743
2804
  session.writeTurn();
2744
2805
  }
2745
2806
  function evictOldestIdle() {
@@ -2840,12 +2901,6 @@ export async function main() {
2840
2901
  }
2841
2902
  }
2842
2903
  existing?.clearStreaming().catch(() => { });
2843
- if (existing) {
2844
- settleInboundMessageId(existing.activeInput?.sourceMessageId, false);
2845
- for (const input of existing.pendingInputs) {
2846
- settleInboundMessageId(input.sourceMessageId, false);
2847
- }
2848
- }
2849
2904
  typingSignals.clear(convoId).catch(() => { });
2850
2905
  typingSignals.dispose(convoId);
2851
2906
  sessions.delete(convoId);
@@ -2876,14 +2931,13 @@ export async function main() {
2876
2931
  return roster ? buildCompactGroupContextLines(roster, 'initial').join('\n') : '';
2877
2932
  },
2878
2933
  currentArtifactAudience: () => ({ memberIds: conversationCache.get(conversationId)?.memberIds, agentId, ownerId }),
2879
- onInputDeferred: (sourceMessageId) => settleInboundMessageId(sourceMessageId, false),
2880
2934
  onInputCompleted: (sourceMessageId) => {
2881
- recoveryCheckpointsFor(conversationId).settle(sourceMessageId);
2882
- settleInboundMessageId(sourceMessageId, true);
2883
2935
  const id = `message:${conversationId}:${sourceMessageId}`;
2884
- void endpoint.getInbound(id).then((entry) => entry
2885
- ? endpoint.setInboundState(id, 'settled', 'native-completed') : undefined)
2886
- .catch((error) => console.error('[canon-host] Failed to settle native input:', error));
2936
+ void endpoint.getInbound(id).then(async (entry) => {
2937
+ if (entry)
2938
+ await endpoint.setInboundState(id, 'settled', 'native-completed');
2939
+ recoveryCheckpointsFor(conversationId).settle(sourceMessageId);
2940
+ }).catch((error) => console.error('[canon-host] Failed to settle native input:', error));
2887
2941
  },
2888
2942
  publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
2889
2943
  }, resumeId);
@@ -2908,30 +2962,6 @@ export async function main() {
2908
2962
  pendingSessionCreations.delete(conversationId);
2909
2963
  }
2910
2964
  }
2911
- const acceptedInboundMessageIds = new Set();
2912
- const inFlightInboundMessageIds = new Set();
2913
- function claimInboundMessageId(messageId) {
2914
- if (!messageId)
2915
- return true;
2916
- if (acceptedInboundMessageIds.has(messageId) || inFlightInboundMessageIds.has(messageId))
2917
- return false;
2918
- inFlightInboundMessageIds.add(messageId);
2919
- return true;
2920
- }
2921
- function settleInboundMessageId(messageId, accepted) {
2922
- if (!messageId)
2923
- return;
2924
- inFlightInboundMessageIds.delete(messageId);
2925
- if (!accepted)
2926
- return;
2927
- acceptedInboundMessageIds.add(messageId);
2928
- while (acceptedInboundMessageIds.size > 2_048) {
2929
- const oldest = acceptedInboundMessageIds.values().next().value;
2930
- if (!oldest)
2931
- break;
2932
- acceptedInboundMessageIds.delete(oldest);
2933
- }
2934
- }
2935
2965
  function handleRuntimeReplyMessage(conversationId, message) {
2936
2966
  if (message.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
2937
2967
  const consumed = approvalManager?.handleMessage(conversationId, {
@@ -2956,13 +2986,6 @@ export async function main() {
2956
2986
  }
2957
2987
  return consumed;
2958
2988
  }
2959
- function persistedConversationCursor(conversationId) {
2960
- return loadRuntimeSessionState(runtimeId, {
2961
- conversationId,
2962
- baseCwd: workingDir,
2963
- workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
2964
- })?.lastInboundMessageId ?? null;
2965
- }
2966
2989
  function persistConversationRecoveryCursor(conversationId, messageId) {
2967
2990
  try {
2968
2991
  saveRuntimeSessionState(runtimeId, {
@@ -2993,23 +3016,24 @@ export async function main() {
2993
3016
  return;
2994
3017
  }
2995
3018
  const convoId = payload.conversationId;
2996
- if (!claimInboundMessageId(m.id))
3019
+ const handoff = await endpoint.acquireInbound(event.id);
3020
+ if (!handoff)
2997
3021
  return;
2998
3022
  recoveryCheckpointsFor(convoId).track(m.id);
2999
3023
  knownConversationIds.add(convoId);
3000
3024
  if (handleRuntimeReplyMessage(convoId, m)) {
3001
- recoveryCheckpointsFor(convoId).settle(m.id);
3002
- settleInboundMessageId(m.id, true);
3003
- await endpoint.setInboundState(`message:${convoId}:${m.id}`, 'settled', 'not-dispatched');
3025
+ if (await handoff.settle('not-dispatched')) {
3026
+ recoveryCheckpointsFor(convoId).settle(m.id);
3027
+ }
3004
3028
  return;
3005
3029
  }
3006
3030
  const sender = m.senderName || m.senderId;
3007
3031
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
3008
3032
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
3009
3033
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
3010
- recoveryCheckpointsFor(convoId).settle(m.id);
3011
- settleInboundMessageId(m.id, true);
3012
- await endpoint.setInboundState(`message:${convoId}:${m.id}`, 'settled', 'not-dispatched');
3034
+ if (await handoff.settle('not-dispatched')) {
3035
+ recoveryCheckpointsFor(convoId).settle(m.id);
3036
+ }
3013
3037
  return;
3014
3038
  }
3015
3039
  await (async () => {
@@ -3109,6 +3133,7 @@ export async function main() {
3109
3133
  materialized: [...replyMedia.materialized, ...materialized],
3110
3134
  });
3111
3135
  const turnModes = {
3136
+ inboundHandoff: handoff,
3112
3137
  ...resolveClaudeTurnModes(participantContext),
3113
3138
  commandContext: commandInput.commandContext,
3114
3139
  replyAuthority: payload.replyAuthority ?? null,
@@ -3129,12 +3154,12 @@ export async function main() {
3129
3154
  return true;
3130
3155
  })().then(async (queued) => {
3131
3156
  if (!queued) {
3132
- recoveryCheckpointsFor(convoId).settle(m.id);
3133
- settleInboundMessageId(m.id, true);
3134
- await endpoint.setInboundState(`message:${convoId}:${m.id}`, 'settled', 'not-dispatched');
3157
+ if (await handoff.settle('not-dispatched')) {
3158
+ recoveryCheckpointsFor(convoId).settle(m.id);
3159
+ }
3135
3160
  }
3136
- }).catch((error) => {
3137
- settleInboundMessageId(m.id, false);
3161
+ }).catch(async (error) => {
3162
+ await handoff.release('queue-failed');
3138
3163
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
3139
3164
  throw error;
3140
3165
  });
@@ -3151,7 +3176,7 @@ export async function main() {
3151
3176
  data: payload });
3152
3177
  },
3153
3178
  onMessageDeleted: (payload) => {
3154
- removeQueuedInput(payload.conversationId, payload.messageId);
3179
+ void removeQueuedInput(payload.conversationId, payload.messageId).catch(console.error);
3155
3180
  },
3156
3181
  onConversationUpdated: (payload) => {
3157
3182
  handleConversationUpdated(payload);
@@ -3209,6 +3234,22 @@ export async function main() {
3209
3234
  queueDepth: session.pendingInputs.length,
3210
3235
  });
3211
3236
  if (action === 'defer') {
3237
+ const dispatching = session.dispatchingInput;
3238
+ if (dispatching?.inboundHandoff) {
3239
+ // Stop owns the pre-submit window too. Register cancellation before
3240
+ // any queued-disposition write; the shared claim fence prevents send.
3241
+ const canceled = dispatching.inboundHandoff.cancel('canceled-before-execution');
3242
+ const dropped = event.type === 'stop_and_drop' ? session.pendingInputs.splice(0) : [];
3243
+ await markSessionPendingInputsRejected(convoId, dropped);
3244
+ for (const input of dropped) {
3245
+ if (!input.inboundHandoff || input.inboundHandoff.phase === 'settled')
3246
+ session.markInputCompleted(input);
3247
+ }
3248
+ if (await canceled) {
3249
+ session.markInputCompleted(dispatching);
3250
+ return;
3251
+ }
3252
+ }
3212
3253
  // Reserved but not yet dispatched: there is nothing in the SDK to stop
3213
3254
  // YET. Consuming the signal here would make a Stop pressed in the
3214
3255
  // accept/artifact window do nothing while the turn it was aimed at ran to
@@ -3240,8 +3281,10 @@ export async function main() {
3240
3281
  await markSessionPendingInputsRejected(convoId, droppedInputs);
3241
3282
  }
3242
3283
  session.markInputCompleted(session.activeInput);
3243
- for (const input of droppedInputs)
3244
- session.markInputCompleted(input);
3284
+ for (const input of droppedInputs) {
3285
+ if (!input.inboundHandoff || input.inboundHandoff.phase === 'settled')
3286
+ session.markInputCompleted(input);
3287
+ }
3245
3288
  if (session.activeInput) {
3246
3289
  session.interruptedTurnKeys.add(session.activeInput.turnKey);
3247
3290
  }
@@ -1,14 +1,18 @@
1
- import type { SDKMessageOrigin, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
1
+ import type { SDKMessageOrigin, SDKResultMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
2
  import type { DeliveryIntent, ModelOption, RuntimeStreamingPayload, TurnLifecycleState, TurnOutputBlock, TurnOutputSnapshot, TurnVerbosity } from '@canonmsg/core';
3
+ import type { EndpointInboundHandoff } from '@canonmsg/core';
3
4
  import type { AgentReplyAuthorityV1 } from '@canonmsg/backend-contracts';
4
5
  import type { TurnArtifactRoutingDecision, TurnArtifactSnapshot } from '@canonmsg/coding-agent-host';
5
6
  export type ClaudeInputKind = 'seed' | 'canon';
6
7
  export type ClaudeArtifactRoutingMode = 'workspace-generated' | 'disabled';
7
8
  export interface ClaudeInputEnvelope {
9
+ inboundHandoff?: EndpointInboundHandoff;
8
10
  commandContext?: string;
9
11
  kind: ClaudeInputKind;
10
- /** Correlates this envelope with its turn result's `user_message_uuid`. */
12
+ /** Correlates this envelope with its turn result's user-message UUIDs. */
11
13
  messageUuid: string;
14
+ /** Other Canon inputs acknowledged by the same result; settle with its final. */
15
+ resultSourceMessageIds?: string[];
12
16
  msg: SDKUserMessage;
13
17
  intent: DeliveryIntent;
14
18
  sourceMessageId: string | null;
@@ -33,6 +37,7 @@ export interface ClaudeInputEnvelope {
33
37
  * grouped so they travel together from the enqueue site onto the envelope.
34
38
  */
35
39
  export interface ClaudeTurnModes {
40
+ inboundHandoff?: EndpointInboundHandoff;
36
41
  commandContext?: string;
37
42
  artifactRoutingMode?: ClaudeArtifactRoutingMode;
38
43
  turnVerbosity?: TurnVerbosity;
@@ -98,6 +103,7 @@ export interface ClaudeCompletedTurnState {
98
103
  turnActivity?: ClaudeTurnActivityState;
99
104
  }
100
105
  export declare function createClaudeInputEnvelope(input: {
106
+ inboundHandoff?: EndpointInboundHandoff;
101
107
  commandContext?: string;
102
108
  kind: ClaudeInputKind;
103
109
  msg: SDKUserMessage;
@@ -150,15 +156,24 @@ export declare function shouldRouteClaudeTurnArtifacts(input: {
150
156
  }): TurnArtifactRoutingDecision;
151
157
  export declare function rememberDispatchedClaudeInput(dispatched: Map<string, ClaudeInputEnvelope>, input: ClaudeInputEnvelope): void;
152
158
  /**
153
- * The envelope a turn result belongs to, matched by `user_message_uuid`.
154
- *
155
- * The SDK coalesces queued inputs — three rapid messages can produce two turns —
156
- * and reports the LAST message of a coalesced batch, which is the correct owner
157
- * of the reply. Entries up to and including the match are consumed, so messages
158
- * folded into that batch do not linger. Returns null when the result carries no
159
- * usable uuid, leaving the caller to fall back.
159
+ * Match only the inputs the SDK explicitly acknowledges. Its plural list is in
160
+ * consumption order and can include messages folded in after the first reply.
161
+ * The last known Canon input owns the final; internal seeds cannot displace it.
162
+ * A singular UUID acknowledges only itself, never everything earlier in our map.
163
+ * Missing/unknown attribution must not fall back to an unrelated active turn.
164
+ */
165
+ export declare function takeClaudeResultOwner(dispatched: Map<string, ClaudeInputEnvelope>, userMessageUuid: unknown, userMessageUuids?: unknown): ClaudeInputEnvelope | null;
166
+ /** Keep coalesced inputs pending until their shared final is delivered or retired. */
167
+ export declare function claudeCompletedSourceMessageIds(input: ClaudeInputEnvelope | null | undefined): string[];
168
+ /**
169
+ * A zeroed/session error may have no input UUID. Treat it as a session failure,
170
+ * never as successful completion of whichever Canon input happens to be active.
171
+ * An unattributed success while foreground work is outstanding is ambiguous too:
172
+ * waiting for an idle echo would wedge that input, because its slot rejects idle
173
+ * until completion. Close the native session and preserve the unsettled input.
174
+ * Explicit foreign/replayed UUIDs remain unrelated results and are ignored.
160
175
  */
161
- export declare function takeClaudeResultOwner(dispatched: Map<string, ClaudeInputEnvelope>, userMessageUuid: unknown): ClaudeInputEnvelope | null;
176
+ export declare function shouldFailClaudeSessionForUnattributedResult(result: Pick<SDKResultMessage, 'subtype' | 'is_error' | 'user_message_uuid' | 'user_message_uuids'>, hasForegroundWork: boolean): boolean;
162
177
  /**
163
178
  * Whether an envelope owns the session's active-turn slot. Only Canon turns do;
164
179
  * any future internal envelope must remain invisible to user-facing turn state.
@@ -304,7 +319,7 @@ export interface ClaudeDispatchDeps {
304
319
  /** Capture the artifact baseline — a workspace walk. */
305
320
  prepareArtifacts: (input: ClaudeInputEnvelope) => Promise<void>;
306
321
  /** Hand the input to the SDK. */
307
- send: (input: ClaudeInputEnvelope) => void;
322
+ send: (input: ClaudeInputEnvelope) => void | Promise<void>;
308
323
  /** Settle an input that will never be answered, and retire its turn. */
309
324
  abandon: (input: ClaudeInputEnvelope, reason: ClaudeDispatchAbandonReason, error?: unknown) => void;
310
325
  }
@@ -12,6 +12,7 @@ export function createClaudeInputEnvelope(input) {
12
12
  return {
13
13
  ...(input.commandContext ? { commandContext: input.commandContext } : {}),
14
14
  kind: input.kind,
15
+ ...(input.inboundHandoff ? { inboundHandoff: input.inboundHandoff } : {}),
15
16
  messageUuid,
16
17
  msg: { ...input.msg, uuid: messageUuid },
17
18
  intent: input.intent ?? 'queue',
@@ -99,28 +100,54 @@ export function rememberDispatchedClaudeInput(dispatched, input) {
99
100
  }
100
101
  }
101
102
  /**
102
- * The envelope a turn result belongs to, matched by `user_message_uuid`.
103
- *
104
- * The SDK coalesces queued inputs — three rapid messages can produce two turns —
105
- * and reports the LAST message of a coalesced batch, which is the correct owner
106
- * of the reply. Entries up to and including the match are consumed, so messages
107
- * folded into that batch do not linger. Returns null when the result carries no
108
- * usable uuid, leaving the caller to fall back.
103
+ * Match only the inputs the SDK explicitly acknowledges. Its plural list is in
104
+ * consumption order and can include messages folded in after the first reply.
105
+ * The last known Canon input owns the final; internal seeds cannot displace it.
106
+ * A singular UUID acknowledges only itself, never everything earlier in our map.
107
+ * Missing/unknown attribution must not fall back to an unrelated active turn.
109
108
  */
110
- export function takeClaudeResultOwner(dispatched, userMessageUuid) {
111
- if (typeof userMessageUuid !== 'string' || !dispatched.has(userMessageUuid)) {
112
- return null;
113
- }
114
- let owner = null;
115
- for (const [key, envelope] of dispatched) {
116
- dispatched.delete(key);
117
- if (key === userMessageUuid) {
118
- owner = envelope;
119
- break;
120
- }
109
+ export function takeClaudeResultOwner(dispatched, userMessageUuid, userMessageUuids) {
110
+ const uuids = Array.isArray(userMessageUuids)
111
+ ? userMessageUuids.filter((uuid) => typeof uuid === 'string' && uuid.length > 0)
112
+ : [];
113
+ if (typeof userMessageUuid === 'string' && !uuids.includes(userMessageUuid)) {
114
+ uuids.push(userMessageUuid);
121
115
  }
116
+ const inputs = [...new Set(uuids)].flatMap((uuid) => {
117
+ const input = dispatched.get(uuid);
118
+ return input ? [input] : [];
119
+ });
120
+ const owner = [...inputs].reverse().find((input) => input.kind === 'canon') ?? inputs.at(-1) ?? null;
121
+ if (!owner)
122
+ return null;
123
+ owner.resultSourceMessageIds = [...new Set(inputs.flatMap(claudeCompletedSourceMessageIds))];
124
+ for (const input of inputs)
125
+ dispatched.delete(input.messageUuid);
122
126
  return owner;
123
127
  }
128
+ /** Keep coalesced inputs pending until their shared final is delivered or retired. */
129
+ export function claudeCompletedSourceMessageIds(input) {
130
+ if (input?.kind !== 'canon')
131
+ return [];
132
+ return [...new Set([
133
+ ...(input.sourceMessageId ? [input.sourceMessageId] : []),
134
+ ...(input.resultSourceMessageIds ?? []),
135
+ ])];
136
+ }
137
+ /**
138
+ * A zeroed/session error may have no input UUID. Treat it as a session failure,
139
+ * never as successful completion of whichever Canon input happens to be active.
140
+ * An unattributed success while foreground work is outstanding is ambiguous too:
141
+ * waiting for an idle echo would wedge that input, because its slot rejects idle
142
+ * until completion. Close the native session and preserve the unsettled input.
143
+ * Explicit foreign/replayed UUIDs remain unrelated results and are ignored.
144
+ */
145
+ export function shouldFailClaudeSessionForUnattributedResult(result, hasForegroundWork) {
146
+ const hasUuid = (typeof result.user_message_uuid === 'string' && result.user_message_uuid.length > 0)
147
+ || (Array.isArray(result.user_message_uuids)
148
+ && result.user_message_uuids.some((uuid) => typeof uuid === 'string' && uuid.length > 0));
149
+ return !hasUuid && (hasForegroundWork || result.is_error || result.subtype !== 'success');
150
+ }
124
151
  /**
125
152
  * Whether an envelope owns the session's active-turn slot. Only Canon turns do;
126
153
  * any future internal envelope must remain invisible to user-facing turn state.
@@ -305,17 +332,30 @@ export async function dispatchClaudeInput(session, input, deps) {
305
332
  catch (error) {
306
333
  failure = { error };
307
334
  }
308
- if (session.dispatchingInput === input)
309
- session.dispatchingInput = null;
310
335
  if (failure) {
336
+ if (session.dispatchingInput === input)
337
+ session.dispatchingInput = null;
311
338
  deps.abandon(input, 'error', failure.error);
312
339
  return;
313
340
  }
314
341
  if (session.closed) {
342
+ if (session.dispatchingInput === input)
343
+ session.dispatchingInput = null;
315
344
  deps.abandon(input, 'closed');
316
345
  return;
317
346
  }
318
- deps.send(input);
347
+ try {
348
+ await deps.send(input);
349
+ }
350
+ catch (error) {
351
+ if (session.dispatchingInput === input)
352
+ session.dispatchingInput = null;
353
+ deps.abandon(input, 'error', error);
354
+ }
355
+ finally {
356
+ if (session.dispatchingInput === input)
357
+ session.dispatchingInput = null;
358
+ }
319
359
  }
320
360
  // ── Turn lifecycle: opening a turn before the SDK says anything ──────
321
361
  //
@@ -1188,12 +1228,9 @@ export async function runClaudeExhaustedFinalDelivery(session, pending, deps) {
1188
1228
  }
1189
1229
  deps.retireTurn();
1190
1230
  }
1191
- // Model options are discovery-driven: the host publishes whatever the Claude
1192
- // Code CLI it spawns reports. A CLI older than this predates whole model
1193
- // families (2.1.220 is the first to know Opus 5), so an old binary silently
1194
- // shortens the picker instead of failing. Keep this at or above the CLI the
1195
- // pinned SDK bundles, so the bundled fallback always satisfies it.
1196
- export const MINIMUM_CLAUDE_CLI_VERSION = [2, 1, 220];
1231
+ // External CLIs must support the same input/result attribution contract as the
1232
+ // pinned SDK. Older binaries fall back to the bundled, audited runtime.
1233
+ export const MINIMUM_CLAUDE_CLI_VERSION = [2, 1, 278];
1197
1234
  export function parseClaudeCliVersion(output) {
1198
1235
  const match = output.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
1199
1236
  return match ? match.slice(1, 4).map(Number) : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.36.1",
3
+ "version": "0.36.3",
4
4
  "description": "Canon channel plugin for Claude Code — messaging where AI agents are first-class citizens",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,11 +30,11 @@
30
30
  "test": "vitest run"
31
31
  },
32
32
  "dependencies": {
33
- "@anthropic-ai/claude-agent-sdk": "0.3.228",
34
- "@canonmsg/agent-sdk": "^11.0.1",
33
+ "@anthropic-ai/claude-agent-sdk": "0.3.278",
34
+ "@canonmsg/agent-sdk": "^11.0.2",
35
35
  "@canonmsg/agent-tools": "^0.11.0",
36
- "@canonmsg/coding-agent-host": "^0.9.0",
37
- "@canonmsg/core": "^13.0.4",
36
+ "@canonmsg/coding-agent-host": "^0.10.0",
37
+ "@canonmsg/core": "^13.0.5",
38
38
  "@canonmsg/rich-cards": "^0.10.6",
39
39
  "@modelcontextprotocol/sdk": "^1.30.0"
40
40
  },