@canonmsg/claude-code-plugin 0.36.2 → 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.2",
4
+ "version": "0.36.3",
5
5
  "channels": [
6
6
  {
7
7
  "server": "canon-channel",
package/dist/host.js CHANGED
@@ -30,7 +30,7 @@ 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';
@@ -1643,16 +1643,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1643
1643
  .updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected')
1644
1644
  .catch(() => { });
1645
1645
  }
1646
- if (input.kind === 'canon' && input.sourceMessageId) {
1647
- const id = `message:${conversationId}:${input.sourceMessageId}`;
1648
- void client.getEndpoint().then(async (engine) => {
1649
- const entry = await engine.getInbound(id);
1650
- if (entry && ['observed', 'offered', 'deferred'].includes(entry.state)) {
1651
- await engine.setInboundState(id, 'deferred', 'native-preflight');
1652
- config.onInputDeferred?.(input.sourceMessageId);
1653
- }
1654
- }).catch((error) => console.error('[canon-host] Failed to defer native input:', error));
1655
- }
1646
+ void input.inboundHandoff?.release('native-preflight').catch((error) => {
1647
+ console.error('[canon-host] Failed to release native input:', error);
1648
+ });
1656
1649
  // resetTurnToIdle releases the slot, tears the published turn down (typing
1657
1650
  // signal, seeded /streaming, turn state) and drains the queue. On a closed
1658
1651
  // session it is a no-op, which is correct: the teardown owns that path.
@@ -1662,17 +1655,26 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1662
1655
  return dispatchClaudeInput(session, input, {
1663
1656
  openTurn,
1664
1657
  markAccepted: (dispatched) => markQueuedMessageAccepted(dispatched.sourceMessageId, dispatched.markAccepted),
1665
- prepareArtifacts: async (input) => {
1666
- await prepareArtifactsForInput(input);
1667
- if (input.kind === 'canon' && input.sourceMessageId) {
1668
- const engine = await client.getEndpoint();
1669
- const id = `message:${conversationId}:${input.sourceMessageId}`;
1670
- if (!await engine.claimInbound(id)) {
1671
- throw new Error('Canon input already has an execution owner.');
1672
- }
1658
+ prepareArtifacts: prepareArtifactsForInput,
1659
+ send: async (input) => {
1660
+ if (input.kind !== 'canon' || !input.sourceMessageId) {
1661
+ sendInput(input);
1662
+ return;
1673
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.`);
1674
1677
  },
1675
- send: sendInput,
1676
1678
  abandon: abandonDispatchedInput,
1677
1679
  });
1678
1680
  }
@@ -1802,12 +1804,12 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1802
1804
  catch { }
1803
1805
  // A native failure cannot prove whether dispatched work ran. Release local
1804
1806
  // in-flight claims without completing endpoint receipts or replaying work.
1805
- const unfinishedIds = new Set([
1807
+ const unfinished = new Set([
1806
1808
  session.activeInput, session.dispatchingInput,
1807
1809
  ...session.pendingInputs, ...dispatchedInputs.values(),
1808
- ].flatMap(claudeCompletedSourceMessageIds));
1809
- for (const sourceMessageId of unfinishedIds)
1810
- config.onInputDeferred?.(sourceMessageId);
1810
+ ]);
1811
+ for (const input of unfinished)
1812
+ void input?.inboundHandoff?.release('session-ended').catch(console.error);
1811
1813
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ended`);
1812
1814
  onSessionEnd(conversationId, session.sdkSessionId);
1813
1815
  }
@@ -2688,6 +2690,9 @@ export async function main() {
2688
2690
  return;
2689
2691
  const saveResume = options.saveResume !== false;
2690
2692
  session.closed = true;
2693
+ for (const input of [session.dispatchingInput, ...session.pendingInputs]) {
2694
+ void input?.inboundHandoff?.release('session-closed').catch(console.error);
2695
+ }
2691
2696
  if (session.typingKeepaliveTimer) {
2692
2697
  clearInterval(session.typingKeepaliveTimer);
2693
2698
  session.typingKeepaliveTimer = null;
@@ -2733,6 +2738,11 @@ export async function main() {
2733
2738
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${options.reason ?? (saveResume ? 'suspended (sessionId saved for resume)' : 'cleared')}`);
2734
2739
  }
2735
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);
2736
2746
  const conversationId = session.conversationId;
2737
2747
  // `dispatchingInput` excludes the dispatch window: the slot is reserved but
2738
2748
  // the SDK has nothing in flight, so there is nothing to interrupt. The
@@ -2757,8 +2767,10 @@ export async function main() {
2757
2767
  const droppedInputs = session.pendingInputs.splice(0);
2758
2768
  await markSessionPendingInputsRejected(conversationId, droppedInputs);
2759
2769
  session.markInputCompleted(session.activeInput);
2760
- for (const input of droppedInputs)
2761
- session.markInputCompleted(input);
2770
+ for (const input of droppedInputs) {
2771
+ if (!input.inboundHandoff || input.inboundHandoff.phase === 'settled')
2772
+ session.markInputCompleted(input);
2773
+ }
2762
2774
  if (session.activeInput) {
2763
2775
  session.interruptedTurnKeys.add(session.activeInput.turnKey);
2764
2776
  }
@@ -2770,13 +2782,14 @@ export async function main() {
2770
2782
  closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
2771
2783
  }
2772
2784
  async function markSessionPendingInputsRejected(conversationId, inputs) {
2773
- await Promise.all(inputs.map((input) => {
2785
+ await Promise.all(inputs.map(async (input) => {
2786
+ await input.inboundHandoff?.cancel('canceled-before-execution');
2774
2787
  if (!input.markAccepted || !input.sourceMessageId)
2775
2788
  return Promise.resolve();
2776
2789
  return client.updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected').catch(() => { });
2777
2790
  }));
2778
2791
  }
2779
- function removeQueuedInput(conversationId, sourceMessageId) {
2792
+ async function removeQueuedInput(conversationId, sourceMessageId) {
2780
2793
  const session = sessions.get(conversationId);
2781
2794
  if (!session || session.pendingInputs.length === 0)
2782
2795
  return;
@@ -2784,8 +2797,10 @@ export async function main() {
2784
2797
  if (removed.length === 0)
2785
2798
  return;
2786
2799
  session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
2787
- for (const input of removed)
2788
- 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
+ }));
2789
2804
  session.writeTurn();
2790
2805
  }
2791
2806
  function evictOldestIdle() {
@@ -2886,12 +2901,6 @@ export async function main() {
2886
2901
  }
2887
2902
  }
2888
2903
  existing?.clearStreaming().catch(() => { });
2889
- if (existing) {
2890
- settleInboundMessageId(existing.activeInput?.sourceMessageId, false);
2891
- for (const input of existing.pendingInputs) {
2892
- settleInboundMessageId(input.sourceMessageId, false);
2893
- }
2894
- }
2895
2904
  typingSignals.clear(convoId).catch(() => { });
2896
2905
  typingSignals.dispose(convoId);
2897
2906
  sessions.delete(convoId);
@@ -2922,14 +2931,13 @@ export async function main() {
2922
2931
  return roster ? buildCompactGroupContextLines(roster, 'initial').join('\n') : '';
2923
2932
  },
2924
2933
  currentArtifactAudience: () => ({ memberIds: conversationCache.get(conversationId)?.memberIds, agentId, ownerId }),
2925
- onInputDeferred: (sourceMessageId) => settleInboundMessageId(sourceMessageId, false),
2926
2934
  onInputCompleted: (sourceMessageId) => {
2927
- recoveryCheckpointsFor(conversationId).settle(sourceMessageId);
2928
- settleInboundMessageId(sourceMessageId, true);
2929
2935
  const id = `message:${conversationId}:${sourceMessageId}`;
2930
- void endpoint.getInbound(id).then((entry) => entry
2931
- ? endpoint.setInboundState(id, 'settled', 'native-completed') : undefined)
2932
- .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));
2933
2941
  },
2934
2942
  publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
2935
2943
  }, resumeId);
@@ -2954,30 +2962,6 @@ export async function main() {
2954
2962
  pendingSessionCreations.delete(conversationId);
2955
2963
  }
2956
2964
  }
2957
- const acceptedInboundMessageIds = new Set();
2958
- const inFlightInboundMessageIds = new Set();
2959
- function claimInboundMessageId(messageId) {
2960
- if (!messageId)
2961
- return true;
2962
- if (acceptedInboundMessageIds.has(messageId) || inFlightInboundMessageIds.has(messageId))
2963
- return false;
2964
- inFlightInboundMessageIds.add(messageId);
2965
- return true;
2966
- }
2967
- function settleInboundMessageId(messageId, accepted) {
2968
- if (!messageId)
2969
- return;
2970
- inFlightInboundMessageIds.delete(messageId);
2971
- if (!accepted)
2972
- return;
2973
- acceptedInboundMessageIds.add(messageId);
2974
- while (acceptedInboundMessageIds.size > 2_048) {
2975
- const oldest = acceptedInboundMessageIds.values().next().value;
2976
- if (!oldest)
2977
- break;
2978
- acceptedInboundMessageIds.delete(oldest);
2979
- }
2980
- }
2981
2965
  function handleRuntimeReplyMessage(conversationId, message) {
2982
2966
  if (message.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
2983
2967
  const consumed = approvalManager?.handleMessage(conversationId, {
@@ -3002,13 +2986,6 @@ export async function main() {
3002
2986
  }
3003
2987
  return consumed;
3004
2988
  }
3005
- function persistedConversationCursor(conversationId) {
3006
- return loadRuntimeSessionState(runtimeId, {
3007
- conversationId,
3008
- baseCwd: workingDir,
3009
- workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
3010
- })?.lastInboundMessageId ?? null;
3011
- }
3012
2989
  function persistConversationRecoveryCursor(conversationId, messageId) {
3013
2990
  try {
3014
2991
  saveRuntimeSessionState(runtimeId, {
@@ -3039,23 +3016,24 @@ export async function main() {
3039
3016
  return;
3040
3017
  }
3041
3018
  const convoId = payload.conversationId;
3042
- if (!claimInboundMessageId(m.id))
3019
+ const handoff = await endpoint.acquireInbound(event.id);
3020
+ if (!handoff)
3043
3021
  return;
3044
3022
  recoveryCheckpointsFor(convoId).track(m.id);
3045
3023
  knownConversationIds.add(convoId);
3046
3024
  if (handleRuntimeReplyMessage(convoId, m)) {
3047
- recoveryCheckpointsFor(convoId).settle(m.id);
3048
- settleInboundMessageId(m.id, true);
3049
- await endpoint.setInboundState(`message:${convoId}:${m.id}`, 'settled', 'not-dispatched');
3025
+ if (await handoff.settle('not-dispatched')) {
3026
+ recoveryCheckpointsFor(convoId).settle(m.id);
3027
+ }
3050
3028
  return;
3051
3029
  }
3052
3030
  const sender = m.senderName || m.senderId;
3053
3031
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
3054
3032
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
3055
3033
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
3056
- recoveryCheckpointsFor(convoId).settle(m.id);
3057
- settleInboundMessageId(m.id, true);
3058
- await endpoint.setInboundState(`message:${convoId}:${m.id}`, 'settled', 'not-dispatched');
3034
+ if (await handoff.settle('not-dispatched')) {
3035
+ recoveryCheckpointsFor(convoId).settle(m.id);
3036
+ }
3059
3037
  return;
3060
3038
  }
3061
3039
  await (async () => {
@@ -3155,6 +3133,7 @@ export async function main() {
3155
3133
  materialized: [...replyMedia.materialized, ...materialized],
3156
3134
  });
3157
3135
  const turnModes = {
3136
+ inboundHandoff: handoff,
3158
3137
  ...resolveClaudeTurnModes(participantContext),
3159
3138
  commandContext: commandInput.commandContext,
3160
3139
  replyAuthority: payload.replyAuthority ?? null,
@@ -3175,12 +3154,12 @@ export async function main() {
3175
3154
  return true;
3176
3155
  })().then(async (queued) => {
3177
3156
  if (!queued) {
3178
- recoveryCheckpointsFor(convoId).settle(m.id);
3179
- settleInboundMessageId(m.id, true);
3180
- await endpoint.setInboundState(`message:${convoId}:${m.id}`, 'settled', 'not-dispatched');
3157
+ if (await handoff.settle('not-dispatched')) {
3158
+ recoveryCheckpointsFor(convoId).settle(m.id);
3159
+ }
3181
3160
  }
3182
- }).catch((error) => {
3183
- settleInboundMessageId(m.id, false);
3161
+ }).catch(async (error) => {
3162
+ await handoff.release('queue-failed');
3184
3163
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
3185
3164
  throw error;
3186
3165
  });
@@ -3197,7 +3176,7 @@ export async function main() {
3197
3176
  data: payload });
3198
3177
  },
3199
3178
  onMessageDeleted: (payload) => {
3200
- removeQueuedInput(payload.conversationId, payload.messageId);
3179
+ void removeQueuedInput(payload.conversationId, payload.messageId).catch(console.error);
3201
3180
  },
3202
3181
  onConversationUpdated: (payload) => {
3203
3182
  handleConversationUpdated(payload);
@@ -3255,6 +3234,22 @@ export async function main() {
3255
3234
  queueDepth: session.pendingInputs.length,
3256
3235
  });
3257
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
+ }
3258
3253
  // Reserved but not yet dispatched: there is nothing in the SDK to stop
3259
3254
  // YET. Consuming the signal here would make a Stop pressed in the
3260
3255
  // accept/artifact window do nothing while the turn it was aimed at ran to
@@ -3286,8 +3281,10 @@ export async function main() {
3286
3281
  await markSessionPendingInputsRejected(convoId, droppedInputs);
3287
3282
  }
3288
3283
  session.markInputCompleted(session.activeInput);
3289
- for (const input of droppedInputs)
3290
- session.markInputCompleted(input);
3284
+ for (const input of droppedInputs) {
3285
+ if (!input.inboundHandoff || input.inboundHandoff.phase === 'settled')
3286
+ session.markInputCompleted(input);
3287
+ }
3291
3288
  if (session.activeInput) {
3292
3289
  session.interruptedTurnKeys.add(session.activeInput.turnKey);
3293
3290
  }
@@ -1,10 +1,12 @@
1
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
12
  /** Correlates this envelope with its turn result's user-message UUIDs. */
@@ -35,6 +37,7 @@ export interface ClaudeInputEnvelope {
35
37
  * grouped so they travel together from the enqueue site onto the envelope.
36
38
  */
37
39
  export interface ClaudeTurnModes {
40
+ inboundHandoff?: EndpointInboundHandoff;
38
41
  commandContext?: string;
39
42
  artifactRoutingMode?: ClaudeArtifactRoutingMode;
40
43
  turnVerbosity?: TurnVerbosity;
@@ -100,6 +103,7 @@ export interface ClaudeCompletedTurnState {
100
103
  turnActivity?: ClaudeTurnActivityState;
101
104
  }
102
105
  export declare function createClaudeInputEnvelope(input: {
106
+ inboundHandoff?: EndpointInboundHandoff;
103
107
  commandContext?: string;
104
108
  kind: ClaudeInputKind;
105
109
  msg: SDKUserMessage;
@@ -315,7 +319,7 @@ export interface ClaudeDispatchDeps {
315
319
  /** Capture the artifact baseline — a workspace walk. */
316
320
  prepareArtifacts: (input: ClaudeInputEnvelope) => Promise<void>;
317
321
  /** Hand the input to the SDK. */
318
- send: (input: ClaudeInputEnvelope) => void;
322
+ send: (input: ClaudeInputEnvelope) => void | Promise<void>;
319
323
  /** Settle an input that will never be answered, and retire its turn. */
320
324
  abandon: (input: ClaudeInputEnvelope, reason: ClaudeDispatchAbandonReason, error?: unknown) => void;
321
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',
@@ -331,17 +332,30 @@ export async function dispatchClaudeInput(session, input, deps) {
331
332
  catch (error) {
332
333
  failure = { error };
333
334
  }
334
- if (session.dispatchingInput === input)
335
- session.dispatchingInput = null;
336
335
  if (failure) {
336
+ if (session.dispatchingInput === input)
337
+ session.dispatchingInput = null;
337
338
  deps.abandon(input, 'error', failure.error);
338
339
  return;
339
340
  }
340
341
  if (session.closed) {
342
+ if (session.dispatchingInput === input)
343
+ session.dispatchingInput = null;
341
344
  deps.abandon(input, 'closed');
342
345
  return;
343
346
  }
344
- 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
+ }
345
359
  }
346
360
  // ── Turn lifecycle: opening a turn before the SDK says anything ──────
347
361
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.36.2",
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",
@@ -31,10 +31,10 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "0.3.278",
34
- "@canonmsg/agent-sdk": "^11.0.1",
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
  },