@canonmsg/claude-code-plugin 0.28.0 → 0.28.1

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.28.0",
4
+ "version": "0.28.1",
5
5
  "channels": [
6
6
  {
7
7
  "server": "canon-channel",
package/dist/host.js CHANGED
@@ -34,7 +34,7 @@ import { runCli } from './cli-entry.js';
34
34
  import { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer } from '@canonmsg/agent-tools';
35
35
  import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
36
36
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
37
- import { applyClaudeSessionControl, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudeTurnFailureNotice, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeFinalText, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
37
+ import { applyClaudeSessionControl, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudeTurnFailureNotice, claudeInputOwnsTurnSlot, rememberDispatchedClaudeInput, takeClaudeResultOwner, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeFinalText, describeUndeliveredClaudeFinal, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
38
38
  import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } from './user-dialog.js';
39
39
  import { collectMissedInboundMessages, createReconnectRecoveryGate, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
40
40
  function parseRuntimeVisibilityPreset(value) {
@@ -316,10 +316,7 @@ function resolveClaudeCliPath() {
316
316
  const override = process.env.CANON_CLAUDE_CLI_PATH?.trim();
317
317
  if (override) {
318
318
  if (existsSync(override)) {
319
- assertExternalClaudeCliVersion(override);
320
- cachedClaudeCliPath = override;
321
- console.error(`[canon-host] claude CLI override: ${override}`);
322
- return override;
319
+ return acceptClaudeCli('override', override);
323
320
  }
324
321
  console.error(`[canon-host] CANON_CLAUDE_CLI_PATH=${override} not found; ignoring`);
325
322
  }
@@ -340,41 +337,41 @@ function resolveClaudeCliPath() {
340
337
  console.error('[canon-host] claude CLI not on PATH; using SDK-bundled binary');
341
338
  return undefined;
342
339
  }
343
- // A stale CLI on PATH would otherwise shadow the newer bundled one and quietly
344
- // drop model families from discovery. Prefer the bundle over a silent downgrade.
345
- let version = null;
340
+ return acceptClaudeCli('on PATH', resolved);
341
+ }
342
+ function readClaudeCliVersionSafely(cliPath) {
346
343
  try {
347
- version = readExternalClaudeCliVersion(resolved);
344
+ const output = execFileSync(cliPath, ['--version'], { encoding: 'utf8' }).trim();
345
+ const version = parseClaudeCliVersion(output);
346
+ if (!version) {
347
+ console.error(`[canon-host] Could not determine Claude Code version from: ${output}`);
348
+ }
349
+ return version;
348
350
  }
349
351
  catch (error) {
350
- console.error(`[canon-host] Could not read the version of the claude CLI at ${resolved}:`, error);
351
- }
352
- if (!version || !isSupportedClaudeCliVersion(version)) {
353
- cachedClaudeCliPath = null;
354
- const found = version ? `is ${formatClaudeCliVersion(version)}` : 'has an unreadable version';
355
- console.error(`[canon-host] claude CLI on PATH (${resolved}) ${found}; `
356
- + `${formatClaudeCliVersion(MINIMUM_CLAUDE_CLI_VERSION)} or newer is required — `
357
- + 'using the SDK-bundled binary instead. Run `claude update` to use your own install.');
358
- return undefined;
359
- }
360
- cachedClaudeCliPath = resolved;
361
- console.error(`[canon-host] claude CLI on PATH: ${resolved} (${formatClaudeCliVersion(version)})`);
362
- return resolved;
363
- }
364
- function readExternalClaudeCliVersion(cliPath) {
365
- const output = execFileSync(cliPath, ['--version'], { encoding: 'utf8' }).trim();
366
- const version = parseClaudeCliVersion(output);
367
- if (!version) {
368
- throw new Error(`Could not determine Claude Code version from: ${output}`);
352
+ console.error(`[canon-host] Could not run ${cliPath} --version:`, error);
353
+ return null;
369
354
  }
370
- return version;
371
355
  }
372
- function assertExternalClaudeCliVersion(cliPath) {
373
- const version = readExternalClaudeCliVersion(cliPath);
374
- if (!isSupportedClaudeCliVersion(version)) {
375
- throw new Error(`Claude Code ${formatClaudeCliVersion(MINIMUM_CLAUDE_CLI_VERSION)} or newer is required; `
376
- + `found ${formatClaudeCliVersion(version)}.`);
377
- }
356
+ /**
357
+ * Take a candidate CLI, or fall back to the SDK-bundled binary. Deliberately
358
+ * total: this is reached from the runtime heartbeat, so an unusable CLI must
359
+ * cost a few model options, never take the host down. A stale binary would
360
+ * otherwise shadow the newer bundled one and quietly shorten model discovery.
361
+ */
362
+ function acceptClaudeCli(source, cliPath) {
363
+ const version = readClaudeCliVersionSafely(cliPath);
364
+ if (version && isSupportedClaudeCliVersion(version)) {
365
+ cachedClaudeCliPath = cliPath;
366
+ console.error(`[canon-host] claude CLI ${source}: ${cliPath} (${formatClaudeCliVersion(version)})`);
367
+ return cliPath;
368
+ }
369
+ cachedClaudeCliPath = null;
370
+ console.error(`[canon-host] claude CLI ${source} (${cliPath}) `
371
+ + `${version ? `is ${formatClaudeCliVersion(version)}` : 'has an unreadable version'}; `
372
+ + `${formatClaudeCliVersion(MINIMUM_CLAUDE_CLI_VERSION)} or newer is required — `
373
+ + 'using the SDK-bundled binary instead. Run `claude update` to use your own install.');
374
+ return undefined;
378
375
  }
379
376
  function toModelOptions(models) {
380
377
  return models.map(claudeModelInfoToOption);
@@ -785,6 +782,22 @@ function createSession(conversationId, environment, agentId, client, typingSigna
785
782
  artifactRoutingMode,
786
783
  }));
787
784
  };
785
+ // Envelopes pulled by the SDK, keyed by the uuid stamped on each message, so a
786
+ // turn result can be matched to its own input. The SDK drains this iterable
787
+ // eagerly and may coalesce queued messages, so neither arrival order nor a
788
+ // single mutable slot can attribute results correctly on their own.
789
+ const dispatchedInputs = new Map();
790
+ // Only Canon turns may own the active-input slot. Result ownership comes from
791
+ // UUID correlation, while this slot drives live tool/dialog state.
792
+ function bindActiveInput(input) {
793
+ rememberDispatchedClaudeInput(dispatchedInputs, input);
794
+ // The slot drives in-turn concerns (tool routing, dialogs, interrupts), so
795
+ // it tracks the newest Canon turn. Result ownership is resolved by uuid, not
796
+ // from here.
797
+ if (!claudeInputOwnsTurnSlot(input))
798
+ return;
799
+ session.activeInput = input;
800
+ }
788
801
  const inputStream = {
789
802
  [Symbol.asyncIterator]() {
790
803
  return {
@@ -794,12 +807,12 @@ function createSession(conversationId, environment, agentId, client, typingSigna
794
807
  // Drain queued messages first (sent before iterator was ready)
795
808
  if (messageQueue.length > 0) {
796
809
  const next = messageQueue.shift();
797
- session.activeInput = next;
810
+ bindActiveInput(next);
798
811
  return Promise.resolve({ done: false, value: next.msg });
799
812
  }
800
813
  return new Promise((resolve) => {
801
814
  resolveInput = (input) => {
802
- session.activeInput = input;
815
+ bindActiveInput(input);
803
816
  resolve({ done: false, value: input.msg });
804
817
  };
805
818
  });
@@ -1105,7 +1118,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1105
1118
  streamingTimer: null,
1106
1119
  idleResetTimer: null,
1107
1120
  finalDeliveryTimer: null,
1108
- seedResponseHandled: !!resumeSessionId, // Resumed sessions don't need seed handling
1109
1121
  pendingInputs: [],
1110
1122
  activeInput: null,
1111
1123
  finalizedTurnKeys: new Set(),
@@ -1565,10 +1577,13 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1565
1577
  const assistantSessionId = msg.session_id;
1566
1578
  if (assistantSessionId)
1567
1579
  session.sdkSessionId = assistantSessionId;
1568
- if (!session.seedResponseHandled) {
1569
- session.seedResponseHandled = true;
1580
+ // Only a Canon turn's text is user-facing. This used to skip the
1581
+ // FIRST assistant message on the assumption it answered the seed,
1582
+ // but the seed is pulled after the first real message, so the guard
1583
+ // swallowed the actual reply — no streamed text and no fallback for
1584
+ // the final. Gate on the owning turn instead of on arrival order.
1585
+ if (!claudeInputOwnsTurnSlot(session.activeInput))
1570
1586
  break;
1571
- }
1572
1587
  const textBlocks = (msg.message?.content ?? [])
1573
1588
  .filter((b) => b.type === 'text')
1574
1589
  .map((b) => b.text);
@@ -1657,7 +1672,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1657
1672
  const resultSessionId = msg.session_id;
1658
1673
  if (resultSessionId)
1659
1674
  session.sdkSessionId = resultSessionId;
1660
- const completedInput = session.activeInput;
1675
+ // Match the result to the envelope that produced it. Falling back to
1676
+ // the slot only when the SDK reports no uuid keeps older runtimes
1677
+ // working, at their existing accuracy.
1678
+ const completedInput = takeClaudeResultOwner(dispatchedInputs, msg.user_message_uuid) ?? session.activeInput;
1661
1679
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
1662
1680
  // Turn artifacts land before the final text reply.
1663
1681
  if (completedInput?.kind === 'canon'
@@ -1690,6 +1708,19 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1690
1708
  interruptedTurnKeys: session.interruptedTurnKeys,
1691
1709
  })
1692
1710
  : false;
1711
+ // Gated finals are dropped without sending. That is correct for the
1712
+ // seed's own turn, but it is also how a misattributed Canon reply
1713
+ // disappears — silently, which is why this class of bug went unseen.
1714
+ // Always say why.
1715
+ if (finalText && !shouldDeliverFinal) {
1716
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
1717
+ + `Final not delivered (${finalText.length} chars): `
1718
+ + describeUndeliveredClaudeFinal({
1719
+ turn: completedInput,
1720
+ finalizedTurnKeys: session.finalizedTurnKeys,
1721
+ interruptedTurnKeys: session.interruptedTurnKeys,
1722
+ }));
1723
+ }
1693
1724
  const finalDelivered = finalText && shouldDeliverFinal
1694
1725
  ? await deliverFinalReply(finalText, completedInput, Boolean(failureNotice))
1695
1726
  : true;
@@ -1718,6 +1749,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1718
1749
  scheduleFinalHandoffReset();
1719
1750
  }
1720
1751
  else {
1752
+ // Nothing durable is coming, so retire the live bubble here.
1753
+ // The delivering path does this after a handoff delay; without
1754
+ // it a gated turn leaves an orphaned /streaming node behind.
1755
+ clearStreaming().catch(() => { });
1721
1756
  resetTurnToIdle();
1722
1757
  }
1723
1758
  }
@@ -1771,20 +1806,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1771
1806
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial ultracode:`, err);
1772
1807
  }
1773
1808
  }
1774
- if (!resumeSessionId) {
1775
- // New session — send seed message to activate streaming input mode
1776
- sendInput(createClaudeInputEnvelope({
1777
- kind: 'seed',
1778
- msg: {
1779
- type: 'user',
1780
- message: {
1781
- role: 'user',
1782
- content: 'You are assisting in an ongoing chat. Reply naturally to the latest participant.',
1783
- },
1784
- parent_tool_use_id: null,
1785
- },
1786
- }));
1787
- }
1788
1809
  // Refresh the canonical runtime descriptor after Claude reports supported models.
1789
1810
  try {
1790
1811
  const models = await q.supportedModels();
@@ -5,6 +5,8 @@ export type ClaudeInputKind = 'seed' | 'canon';
5
5
  export type ClaudeArtifactRoutingMode = 'workspace-generated' | 'disabled';
6
6
  export interface ClaudeInputEnvelope {
7
7
  kind: ClaudeInputKind;
8
+ /** Correlates this envelope with its turn result's `user_message_uuid`. */
9
+ messageUuid: string;
8
10
  msg: SDKUserMessage;
9
11
  intent: DeliveryIntent;
10
12
  sourceMessageId: string | null;
@@ -68,6 +70,28 @@ export declare function shouldDeliverClaudeFinal(input: {
68
70
  finalizedTurnKeys: ReadonlySet<string>;
69
71
  interruptedTurnKeys: ReadonlySet<string>;
70
72
  }): boolean;
73
+ export declare function rememberDispatchedClaudeInput(dispatched: Map<string, ClaudeInputEnvelope>, input: ClaudeInputEnvelope): void;
74
+ /**
75
+ * The envelope a turn result belongs to, matched by `user_message_uuid`.
76
+ *
77
+ * The SDK coalesces queued inputs — three rapid messages can produce two turns —
78
+ * and reports the LAST message of a coalesced batch, which is the correct owner
79
+ * of the reply. Entries up to and including the match are consumed, so messages
80
+ * folded into that batch do not linger. Returns null when the result carries no
81
+ * usable uuid, leaving the caller to fall back.
82
+ */
83
+ export declare function takeClaudeResultOwner(dispatched: Map<string, ClaudeInputEnvelope>, userMessageUuid: unknown): ClaudeInputEnvelope | null;
84
+ /**
85
+ * Whether an envelope owns the session's active-turn slot. Only Canon turns do;
86
+ * any future internal envelope must remain invisible to user-facing turn state.
87
+ */
88
+ export declare function claudeInputOwnsTurnSlot(input: Pick<ClaudeInputEnvelope, 'kind'> | null | undefined): boolean;
89
+ /** Why a non-empty final was gated instead of sent. Diagnostics only. */
90
+ export declare function describeUndeliveredClaudeFinal(input: {
91
+ turn: ClaudeInputEnvelope | null;
92
+ finalizedTurnKeys: ReadonlySet<string>;
93
+ interruptedTurnKeys: ReadonlySet<string>;
94
+ }): string;
71
95
  export declare function resetClaudeCompletedTurnState(session: ClaudeCompletedTurnState): void;
72
96
  export declare function claudeOriginForCanonSender(input: {
73
97
  senderType?: string | null;
@@ -3,9 +3,14 @@ import { USAGE_LIMIT_ERROR_PREFIXES } from '@anthropic-ai/claude-agent-sdk';
3
3
  export function createClaudeInputEnvelope(input) {
4
4
  const sourceMessageId = input.sourceMessageId ?? null;
5
5
  const fallbackId = randomUUID();
6
+ // Stamped so each turn result can be matched back to the envelope that caused
7
+ // it via SDKResultMessage.user_message_uuid, instead of inferring ownership
8
+ // from a mutable slot and arrival order.
9
+ const messageUuid = randomUUID();
6
10
  return {
7
11
  kind: input.kind,
8
- msg: input.msg,
12
+ messageUuid,
13
+ msg: { ...input.msg, uuid: messageUuid },
9
14
  intent: input.intent ?? 'queue',
10
15
  sourceMessageId,
11
16
  markAccepted: Boolean(input.markAccepted),
@@ -49,6 +54,59 @@ export function shouldDeliverClaudeFinal(input) {
49
54
  return false;
50
55
  return true;
51
56
  }
57
+ /** Bounds the correlation map if the SDK ever stops reporting a uuid we sent. */
58
+ const MAX_TRACKED_DISPATCHED_INPUTS = 64;
59
+ export function rememberDispatchedClaudeInput(dispatched, input) {
60
+ dispatched.set(input.messageUuid, input);
61
+ while (dispatched.size > MAX_TRACKED_DISPATCHED_INPUTS) {
62
+ const oldest = dispatched.keys().next();
63
+ if (oldest.done)
64
+ break;
65
+ dispatched.delete(oldest.value);
66
+ }
67
+ }
68
+ /**
69
+ * The envelope a turn result belongs to, matched by `user_message_uuid`.
70
+ *
71
+ * The SDK coalesces queued inputs — three rapid messages can produce two turns —
72
+ * and reports the LAST message of a coalesced batch, which is the correct owner
73
+ * of the reply. Entries up to and including the match are consumed, so messages
74
+ * folded into that batch do not linger. Returns null when the result carries no
75
+ * usable uuid, leaving the caller to fall back.
76
+ */
77
+ export function takeClaudeResultOwner(dispatched, userMessageUuid) {
78
+ if (typeof userMessageUuid !== 'string' || !dispatched.has(userMessageUuid)) {
79
+ return null;
80
+ }
81
+ let owner = null;
82
+ for (const [key, envelope] of dispatched) {
83
+ dispatched.delete(key);
84
+ if (key === userMessageUuid) {
85
+ owner = envelope;
86
+ break;
87
+ }
88
+ }
89
+ return owner;
90
+ }
91
+ /**
92
+ * Whether an envelope owns the session's active-turn slot. Only Canon turns do;
93
+ * any future internal envelope must remain invisible to user-facing turn state.
94
+ */
95
+ export function claudeInputOwnsTurnSlot(input) {
96
+ return input?.kind === 'canon';
97
+ }
98
+ /** Why a non-empty final was gated instead of sent. Diagnostics only. */
99
+ export function describeUndeliveredClaudeFinal(input) {
100
+ if (!input.turn)
101
+ return 'no Canon turn owns this result';
102
+ if (input.turn.kind !== 'canon')
103
+ return `owning turn is '${input.turn.kind}'`;
104
+ if (input.interruptedTurnKeys.has(input.turn.turnKey))
105
+ return 'turn was interrupted';
106
+ if (input.finalizedTurnKeys.has(input.turn.turnKey))
107
+ return 'turn was already finalized';
108
+ return 'turn did not qualify for delivery';
109
+ }
52
110
  export function resetClaudeCompletedTurnState(session) {
53
111
  session.state.state = 'idle';
54
112
  session.currentTurnId = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
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",