@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/host.js +77 -56
- package/dist/session-state.d.ts +24 -0
- package/dist/session-state.js +59 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
340
|
+
return acceptClaudeCli('on PATH', resolved);
|
|
341
|
+
}
|
|
342
|
+
function readClaudeCliVersionSafely(cliPath) {
|
|
346
343
|
try {
|
|
347
|
-
|
|
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
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1569
|
-
|
|
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
|
-
|
|
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();
|
package/dist/session-state.d.ts
CHANGED
|
@@ -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;
|
package/dist/session-state.js
CHANGED
|
@@ -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
|
-
|
|
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