@canonmsg/claude-code-plugin 0.27.3 → 0.28.0
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/README.md +1 -1
- package/dist/host.js +88 -46
- package/dist/session-state.d.ts +54 -2
- package/dist/session-state.js +132 -0
- package/dist/tool-policy.d.ts +6 -0
- package/dist/tool-policy.js +46 -15
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Connect Claude Code to [Canon](https://github.com/HeyBobChan/canon) — a messag
|
|
|
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.
|
|
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. A `PATH` install below that minimum is skipped in favour of the bundled runtime; a `CANON_CLAUDE_CLI_PATH` below it is a hard error.
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
# Install
|
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, claudeModelInfoToOption, confirmClaudeInterrupt, createClaudeInputEnvelope, formatClaudeControlError, isClaudeCustomModelOption, mergeClaudeDiscoveredModelOptions, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.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';
|
|
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) {
|
|
@@ -307,7 +307,8 @@ let cachedClaudeCliPath;
|
|
|
307
307
|
// claude-agent-sdk-darwin-arm64. On some macOS setups that exact binary gets
|
|
308
308
|
// SIGKILL'd at launch (path-based code-sign enforcement) even though a
|
|
309
309
|
// byte-identical copy at ~/.local/bin/claude runs fine. Prefer whatever is on
|
|
310
|
-
// PATH; fall back to the bundled one
|
|
310
|
+
// PATH; fall back to the bundled one — including when the PATH copy is older
|
|
311
|
+
// than MINIMUM_CLAUDE_CLI_VERSION, since the bundle is always new enough.
|
|
311
312
|
function resolveClaudeCliPath() {
|
|
312
313
|
if (cachedClaudeCliPath !== undefined) {
|
|
313
314
|
return cachedClaudeCliPath ?? undefined;
|
|
@@ -322,39 +323,57 @@ function resolveClaudeCliPath() {
|
|
|
322
323
|
}
|
|
323
324
|
console.error(`[canon-host] CANON_CLAUDE_CLI_PATH=${override} not found; ignoring`);
|
|
324
325
|
}
|
|
326
|
+
let resolved;
|
|
325
327
|
try {
|
|
326
|
-
const
|
|
328
|
+
const found = execFileSync('/bin/sh', ['-c', 'command -v claude'], {
|
|
327
329
|
encoding: 'utf8',
|
|
328
330
|
}).trim();
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
cachedClaudeCliPath = resolved;
|
|
332
|
-
console.error(`[canon-host] claude CLI on PATH: ${resolved}`);
|
|
333
|
-
return resolved;
|
|
331
|
+
if (found && existsSync(found)) {
|
|
332
|
+
resolved = found;
|
|
334
333
|
}
|
|
335
334
|
}
|
|
336
335
|
catch {
|
|
337
336
|
// `command -v` exits non-zero when claude isn't on PATH; fall through.
|
|
338
337
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
338
|
+
if (!resolved) {
|
|
339
|
+
cachedClaudeCliPath = null;
|
|
340
|
+
console.error('[canon-host] claude CLI not on PATH; using SDK-bundled binary');
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
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;
|
|
346
|
+
try {
|
|
347
|
+
version = readExternalClaudeCliVersion(resolved);
|
|
348
|
+
}
|
|
349
|
+
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;
|
|
342
363
|
}
|
|
343
|
-
function
|
|
364
|
+
function readExternalClaudeCliVersion(cliPath) {
|
|
344
365
|
const output = execFileSync(cliPath, ['--version'], { encoding: 'utf8' }).trim();
|
|
345
|
-
const
|
|
346
|
-
if (!
|
|
366
|
+
const version = parseClaudeCliVersion(output);
|
|
367
|
+
if (!version) {
|
|
347
368
|
throw new Error(`Could not determine Claude Code version from: ${output}`);
|
|
348
369
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (!supported) {
|
|
357
|
-
throw new Error(`Claude Code ${minimum.join('.')} or newer is required; found ${match[0]}.`);
|
|
370
|
+
return version;
|
|
371
|
+
}
|
|
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)}.`);
|
|
358
377
|
}
|
|
359
378
|
}
|
|
360
379
|
function toModelOptions(models) {
|
|
@@ -398,16 +417,13 @@ function resolveExecutionFallbackReason(environment) {
|
|
|
398
417
|
? null
|
|
399
418
|
: environment.reason;
|
|
400
419
|
}
|
|
401
|
-
const CLAUDE_SUPPLEMENTAL_MODEL_PROBES = ['opus', 'fable'];
|
|
402
420
|
async function detectRuntimeModels(cwd) {
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
detectSupplementalRuntimeModels(cwd),
|
|
406
|
-
]);
|
|
421
|
+
const discovered = await detectRuntimeModelsForSelection(cwd, 'default');
|
|
422
|
+
const supplemental = await detectSupplementalRuntimeModels(cwd, deriveClaudeSupplementalModelProbes(discovered));
|
|
407
423
|
return mergeClaudeDiscoveredModelOptions(discovered, ...supplemental);
|
|
408
424
|
}
|
|
409
|
-
async function detectSupplementalRuntimeModels(cwd) {
|
|
410
|
-
return Promise.all(
|
|
425
|
+
async function detectSupplementalRuntimeModels(cwd, aliases) {
|
|
426
|
+
return Promise.all(aliases.map(async (model) => {
|
|
411
427
|
try {
|
|
412
428
|
const options = await detectRuntimeModelsForSelection(cwd, model);
|
|
413
429
|
const option = options.find((entry) => entry.value === model);
|
|
@@ -1044,6 +1060,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1044
1060
|
approvalManager: config.approvalManager,
|
|
1045
1061
|
canRequestApproval: config.canRequestApproval,
|
|
1046
1062
|
allowedNonOwnerTools: allowedNonOwnerClaudeTools,
|
|
1063
|
+
ruleForcedAsk: Boolean(options.matchedAskRule),
|
|
1047
1064
|
// File-change preview for Edit/Write-class approvals: canUseTool fires
|
|
1048
1065
|
// before the mutation lands, so read the pre-image from disk and splice
|
|
1049
1066
|
// the edit in memory. Best-effort — failure logs and omits the diff.
|
|
@@ -1209,7 +1226,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1209
1226
|
resetTurnToIdle();
|
|
1210
1227
|
}, FINAL_MESSAGE_HANDOFF_MS);
|
|
1211
1228
|
}
|
|
1212
|
-
function markPendingFinalDelivery(finalText, turn) {
|
|
1229
|
+
function markPendingFinalDelivery(finalText, turn, suppressAutoReply = false) {
|
|
1213
1230
|
clearIdleResetTimer();
|
|
1214
1231
|
const retryCount = session.pendingFinalDelivery?.turnKey === turn.turnKey
|
|
1215
1232
|
? session.pendingFinalDelivery.retryCount
|
|
@@ -1219,6 +1236,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1219
1236
|
turnKey: turn.turnKey,
|
|
1220
1237
|
text: finalText,
|
|
1221
1238
|
retryCount,
|
|
1239
|
+
...(suppressAutoReply ? { suppressAutoReply: true } : {}),
|
|
1222
1240
|
messageId: buildClaudeFinalMessageId({
|
|
1223
1241
|
agentId,
|
|
1224
1242
|
conversationId,
|
|
@@ -1233,7 +1251,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1233
1251
|
writeTurn();
|
|
1234
1252
|
typingSignals.start(conversationId, 'typing').catch(() => { });
|
|
1235
1253
|
}
|
|
1236
|
-
async function deliverFinalReply(finalText, turn = session.activeInput) {
|
|
1254
|
+
async function deliverFinalReply(finalText, turn = session.activeInput, suppressAutoReply = false) {
|
|
1237
1255
|
if (!shouldDeliverClaudeFinal({
|
|
1238
1256
|
turn,
|
|
1239
1257
|
finalizedTurnKeys: session.finalizedTurnKeys,
|
|
@@ -1255,16 +1273,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1255
1273
|
...(session.activeSelfContextId
|
|
1256
1274
|
? { selfContextId: session.activeSelfContextId }
|
|
1257
1275
|
: {}),
|
|
1258
|
-
metadata: {
|
|
1276
|
+
metadata: buildClaudeFinalTurnMetadata({
|
|
1259
1277
|
turnId: session.currentTurnId,
|
|
1260
1278
|
turnKey: deliverTurn.turnKey,
|
|
1261
|
-
sourceMessageId: deliverTurn.sourceMessageId
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
: {}),
|
|
1267
|
-
},
|
|
1279
|
+
sourceMessageId: deliverTurn.sourceMessageId,
|
|
1280
|
+
deliveryIntent: session.lastAcceptedIntent,
|
|
1281
|
+
turnTrail: getFinalTurnTrail(),
|
|
1282
|
+
suppressAutoReply,
|
|
1283
|
+
}),
|
|
1268
1284
|
});
|
|
1269
1285
|
session.finalizedTurnKeys.add(deliverTurn.turnKey);
|
|
1270
1286
|
markInputCompleted(deliverTurn);
|
|
@@ -1276,7 +1292,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1276
1292
|
return true;
|
|
1277
1293
|
}
|
|
1278
1294
|
catch (err) {
|
|
1279
|
-
markPendingFinalDelivery(finalText, deliverTurn);
|
|
1295
|
+
markPendingFinalDelivery(finalText, deliverTurn, suppressAutoReply);
|
|
1280
1296
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
|
|
1281
1297
|
return false;
|
|
1282
1298
|
}
|
|
@@ -1336,7 +1352,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1336
1352
|
const retryTurn = session.activeInput?.turnKey === pending.turnKey
|
|
1337
1353
|
? session.activeInput
|
|
1338
1354
|
: null;
|
|
1339
|
-
void deliverFinalReply(pending.text, retryTurn).then((sent) => {
|
|
1355
|
+
void deliverFinalReply(pending.text, retryTurn, pending.suppressAutoReply ?? false).then((sent) => {
|
|
1340
1356
|
if (sent) {
|
|
1341
1357
|
scheduleFinalHandoffReset();
|
|
1342
1358
|
return;
|
|
@@ -1651,7 +1667,22 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1651
1667
|
const resultText = typeof msg.result === 'string'
|
|
1652
1668
|
? msg.result.trim()
|
|
1653
1669
|
: '';
|
|
1654
|
-
|
|
1670
|
+
// Error results carry no `result` text — without a notice the
|
|
1671
|
+
// turn would end silently and the agent just goes idle in chat.
|
|
1672
|
+
const failureNotice = buildClaudeTurnFailureNotice({
|
|
1673
|
+
subtype: msg.subtype,
|
|
1674
|
+
errors: msg.errors,
|
|
1675
|
+
});
|
|
1676
|
+
if (failureNotice) {
|
|
1677
|
+
const errorList = Array.isArray(msg.errors) ? msg.errors : [];
|
|
1678
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn failed (${msg.subtype}): `
|
|
1679
|
+
+ (errorList.join(' | ') || 'no error detail'));
|
|
1680
|
+
}
|
|
1681
|
+
const finalText = composeClaudeFinalText({
|
|
1682
|
+
resultText,
|
|
1683
|
+
streamedText: session.pendingFinalText,
|
|
1684
|
+
failureNotice,
|
|
1685
|
+
});
|
|
1655
1686
|
const shouldDeliverFinal = finalText
|
|
1656
1687
|
? shouldDeliverClaudeFinal({
|
|
1657
1688
|
turn: completedInput,
|
|
@@ -1660,7 +1691,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1660
1691
|
})
|
|
1661
1692
|
: false;
|
|
1662
1693
|
const finalDelivered = finalText && shouldDeliverFinal
|
|
1663
|
-
? await deliverFinalReply(finalText, completedInput)
|
|
1694
|
+
? await deliverFinalReply(finalText, completedInput, Boolean(failureNotice))
|
|
1664
1695
|
: true;
|
|
1665
1696
|
try {
|
|
1666
1697
|
const usage = await q.getContextUsage();
|
|
@@ -1757,9 +1788,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1757
1788
|
// Refresh the canonical runtime descriptor after Claude reports supported models.
|
|
1758
1789
|
try {
|
|
1759
1790
|
const models = await q.supportedModels();
|
|
1760
|
-
const
|
|
1791
|
+
const discoveredOptions = toModelOptions(models);
|
|
1792
|
+
const supplemental = await detectSupplementalRuntimeModels(cwd, deriveClaudeSupplementalModelProbes(discoveredOptions));
|
|
1761
1793
|
const modelList = resolveClaudeModelOptions({
|
|
1762
|
-
discovered: mergeClaudeDiscoveredModelOptions(
|
|
1794
|
+
discovered: mergeClaudeDiscoveredModelOptions(discoveredOptions, ...supplemental),
|
|
1763
1795
|
selectedModel: session.state.model,
|
|
1764
1796
|
});
|
|
1765
1797
|
session.availableModels = modelList;
|
|
@@ -2539,6 +2571,11 @@ export async function main() {
|
|
|
2539
2571
|
content: messageContent,
|
|
2540
2572
|
},
|
|
2541
2573
|
parent_tool_use_id: null,
|
|
2574
|
+
origin: claudeOriginForCanonSender({
|
|
2575
|
+
senderType: m.senderType,
|
|
2576
|
+
senderId: m.senderId,
|
|
2577
|
+
senderName: m.senderName,
|
|
2578
|
+
}),
|
|
2542
2579
|
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
|
|
2543
2580
|
return 'queued';
|
|
2544
2581
|
}
|
|
@@ -2790,6 +2827,11 @@ export async function main() {
|
|
|
2790
2827
|
content: messageContent,
|
|
2791
2828
|
},
|
|
2792
2829
|
parent_tool_use_id: null,
|
|
2830
|
+
origin: claudeOriginForCanonSender({
|
|
2831
|
+
senderType: m.senderType,
|
|
2832
|
+
senderId: m.senderId,
|
|
2833
|
+
senderName: m.senderName,
|
|
2834
|
+
}),
|
|
2793
2835
|
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
|
|
2794
2836
|
return true;
|
|
2795
2837
|
})().then((queued) => {
|
package/dist/session-state.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PermissionMode, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
1
|
+
import type { PermissionMode, SDKMessageOrigin, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
2
2
|
import type { DeliveryIntent, ModelOption, TurnLifecycleState } from '@canonmsg/core';
|
|
3
3
|
import type { TurnArtifactSnapshot } from '@canonmsg/coding-agent-host';
|
|
4
4
|
export type ClaudeInputKind = 'seed' | 'canon';
|
|
@@ -25,6 +25,8 @@ export interface ClaudePendingFinalDelivery {
|
|
|
25
25
|
text: string;
|
|
26
26
|
messageId: string;
|
|
27
27
|
retryCount: number;
|
|
28
|
+
/** Carried so a retried failure notice keeps suppressing agent auto-replies. */
|
|
29
|
+
suppressAutoReply?: boolean;
|
|
28
30
|
}
|
|
29
31
|
export interface ClaudeCompletedTurnState {
|
|
30
32
|
state: {
|
|
@@ -67,6 +69,48 @@ export declare function shouldDeliverClaudeFinal(input: {
|
|
|
67
69
|
interruptedTurnKeys: ReadonlySet<string>;
|
|
68
70
|
}): boolean;
|
|
69
71
|
export declare function resetClaudeCompletedTurnState(session: ClaudeCompletedTurnState): void;
|
|
72
|
+
export declare function claudeOriginForCanonSender(input: {
|
|
73
|
+
senderType?: string | null;
|
|
74
|
+
senderId: string;
|
|
75
|
+
senderName?: string | null;
|
|
76
|
+
}): SDKMessageOrigin;
|
|
77
|
+
/**
|
|
78
|
+
* Metadata for a completed-turn message. `suppressAutoReply` is set for turns
|
|
79
|
+
* that ended in failure: a failure notice is not a handoff, so it must not
|
|
80
|
+
* trigger a peer agent's auto-reply and start an error-response exchange.
|
|
81
|
+
*/
|
|
82
|
+
export declare function buildClaudeFinalTurnMetadata(input: {
|
|
83
|
+
turnId?: string | null;
|
|
84
|
+
turnKey: string;
|
|
85
|
+
sourceMessageId?: string | null;
|
|
86
|
+
deliveryIntent?: DeliveryIntent | null;
|
|
87
|
+
turnTrail?: ReadonlyArray<unknown>;
|
|
88
|
+
suppressAutoReply?: boolean;
|
|
89
|
+
}): Record<string, unknown>;
|
|
90
|
+
/**
|
|
91
|
+
* A chat-deliverable notice for a turn that ended on an SDK error result.
|
|
92
|
+
* Without it the agent goes from working to idle with no message at all —
|
|
93
|
+
* the error subtypes carry no `result` text, and `errors` was never read.
|
|
94
|
+
* Returns null for success results.
|
|
95
|
+
*/
|
|
96
|
+
export declare function buildClaudeTurnFailureNotice(input: {
|
|
97
|
+
subtype: unknown;
|
|
98
|
+
errors?: unknown;
|
|
99
|
+
}): string | null;
|
|
100
|
+
/**
|
|
101
|
+
* Final chat text for a completed turn. Success keeps today's behaviour
|
|
102
|
+
* (result text, else the streamed text). A failure notice is appended to any
|
|
103
|
+
* partial output — partial text alone would read as a successful reply.
|
|
104
|
+
*/
|
|
105
|
+
export declare function composeClaudeFinalText(input: {
|
|
106
|
+
resultText?: string | null;
|
|
107
|
+
streamedText?: string | null;
|
|
108
|
+
failureNotice?: string | null;
|
|
109
|
+
}): string | null;
|
|
110
|
+
export declare const MINIMUM_CLAUDE_CLI_VERSION: readonly number[];
|
|
111
|
+
export declare function parseClaudeCliVersion(output: string): number[] | null;
|
|
112
|
+
export declare function formatClaudeCliVersion(version: readonly number[]): string;
|
|
113
|
+
export declare function isSupportedClaudeCliVersion(installed: readonly number[], minimum?: readonly number[]): boolean;
|
|
70
114
|
export interface ClaudeModelInfoLike {
|
|
71
115
|
value: string;
|
|
72
116
|
displayName: string;
|
|
@@ -74,6 +118,14 @@ export interface ClaudeModelInfoLike {
|
|
|
74
118
|
}
|
|
75
119
|
export declare function claudeModelInfoToOption(model: ClaudeModelInfoLike): ModelOption;
|
|
76
120
|
export declare function isClaudeCustomModelOption(option: ModelOption | null | undefined): boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Bare model aliases worth probing beyond what default discovery lists.
|
|
123
|
+
* Discovery decorates values (`opus[1m]`, `claude-fable-5[1m]`) while the
|
|
124
|
+
* plain alias (`opus`, `fable`) is selectable but unlisted; deriving the
|
|
125
|
+
* aliases from the discovered set keeps the picker discovery-driven instead
|
|
126
|
+
* of needing a code change every time a model family ships.
|
|
127
|
+
*/
|
|
128
|
+
export declare function deriveClaudeSupplementalModelProbes(discovered: ReadonlyArray<ModelOption>): string[];
|
|
77
129
|
export declare function mergeClaudeDiscoveredModelOptions(...groups: ReadonlyArray<ReadonlyArray<ModelOption>>): ModelOption[];
|
|
78
130
|
export declare function resolveClaudeModelOptions(input: {
|
|
79
131
|
discovered: ReadonlyArray<ModelOption>;
|
|
@@ -82,7 +134,7 @@ export declare function resolveClaudeModelOptions(input: {
|
|
|
82
134
|
export declare function formatClaudeControlError(error: unknown): string;
|
|
83
135
|
export declare function confirmClaudeInterrupt(input: {
|
|
84
136
|
active: boolean;
|
|
85
|
-
interrupt: () => Promise<
|
|
137
|
+
interrupt: () => Promise<unknown>;
|
|
86
138
|
}): Promise<'confirmed' | 'defer'>;
|
|
87
139
|
/** Requested session-control values from the control plane (raw, pre-validation). */
|
|
88
140
|
export interface ClaudeSessionControlInput {
|
package/dist/session-state.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { USAGE_LIMIT_ERROR_PREFIXES } from '@anthropic-ai/claude-agent-sdk';
|
|
2
3
|
export function createClaudeInputEnvelope(input) {
|
|
3
4
|
const sourceMessageId = input.sourceMessageId ?? null;
|
|
4
5
|
const fallbackId = randomUUID();
|
|
@@ -60,6 +61,110 @@ export function resetClaudeCompletedTurnState(session) {
|
|
|
60
61
|
session.toolInProgress = false;
|
|
61
62
|
session.turnState = 'idle';
|
|
62
63
|
}
|
|
64
|
+
// Provenance for a Canon message entering the SDK. From 0.3.220 an absent
|
|
65
|
+
// `origin` is "unattributed" and fails closed at strict isHuman() gates, so a
|
|
66
|
+
// human sender must be stamped explicitly rather than left to default. Agent
|
|
67
|
+
// senders keep their identity as peers instead of being laundered into humans.
|
|
68
|
+
export function claudeOriginForCanonSender(input) {
|
|
69
|
+
if (input.senderType === 'human')
|
|
70
|
+
return { kind: 'human' };
|
|
71
|
+
const name = input.senderName?.trim();
|
|
72
|
+
return { kind: 'peer', from: input.senderId, ...(name ? { name } : {}) };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Metadata for a completed-turn message. `suppressAutoReply` is set for turns
|
|
76
|
+
* that ended in failure: a failure notice is not a handoff, so it must not
|
|
77
|
+
* trigger a peer agent's auto-reply and start an error-response exchange.
|
|
78
|
+
*/
|
|
79
|
+
export function buildClaudeFinalTurnMetadata(input) {
|
|
80
|
+
return {
|
|
81
|
+
turnId: input.turnId ?? null,
|
|
82
|
+
turnKey: input.turnKey,
|
|
83
|
+
sourceMessageId: input.sourceMessageId ?? undefined,
|
|
84
|
+
turnSemantics: 'turn_complete',
|
|
85
|
+
...(input.suppressAutoReply ? { replyBehavior: 'suppress_auto_reply' } : {}),
|
|
86
|
+
deliveryIntent: input.deliveryIntent ?? undefined,
|
|
87
|
+
...(input.turnTrail && input.turnTrail.length > 0 ? { turnTrail: input.turnTrail } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const CLAUDE_FAILURE_DETAIL_MAX_CHARS = 280;
|
|
91
|
+
function claudeFailureDetail(errors) {
|
|
92
|
+
if (!Array.isArray(errors))
|
|
93
|
+
return null;
|
|
94
|
+
const first = errors.find((entry) => typeof entry === 'string' && entry.trim());
|
|
95
|
+
if (typeof first !== 'string')
|
|
96
|
+
return null;
|
|
97
|
+
const trimmed = first.trim();
|
|
98
|
+
return trimmed.length > CLAUDE_FAILURE_DETAIL_MAX_CHARS
|
|
99
|
+
? `${trimmed.slice(0, CLAUDE_FAILURE_DETAIL_MAX_CHARS - 1)}…`
|
|
100
|
+
: trimmed;
|
|
101
|
+
}
|
|
102
|
+
function claudeUsageLimitDetail(errors) {
|
|
103
|
+
if (!Array.isArray(errors))
|
|
104
|
+
return null;
|
|
105
|
+
const match = errors.find((entry) => typeof entry === 'string'
|
|
106
|
+
&& USAGE_LIMIT_ERROR_PREFIXES.some((prefix) => entry.startsWith(prefix)));
|
|
107
|
+
return typeof match === 'string' ? claudeFailureDetail([match]) : null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A chat-deliverable notice for a turn that ended on an SDK error result.
|
|
111
|
+
* Without it the agent goes from working to idle with no message at all —
|
|
112
|
+
* the error subtypes carry no `result` text, and `errors` was never read.
|
|
113
|
+
* Returns null for success results.
|
|
114
|
+
*/
|
|
115
|
+
export function buildClaudeTurnFailureNotice(input) {
|
|
116
|
+
if (typeof input.subtype !== 'string' || !input.subtype.startsWith('error')) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const usageLimit = claudeUsageLimitDetail(input.errors);
|
|
120
|
+
if (usageLimit) {
|
|
121
|
+
return `I've hit a usage limit, so this turn didn't finish: ${usageLimit}`;
|
|
122
|
+
}
|
|
123
|
+
switch (input.subtype) {
|
|
124
|
+
case 'error_max_turns':
|
|
125
|
+
return 'I stopped this turn after reaching its maximum number of turns. Send a follow-up message to continue.';
|
|
126
|
+
case 'error_max_budget_usd':
|
|
127
|
+
return 'I stopped this turn after reaching its spending budget.';
|
|
128
|
+
default: {
|
|
129
|
+
const detail = claudeFailureDetail(input.errors);
|
|
130
|
+
return detail
|
|
131
|
+
? `This turn failed before I could reply: ${detail}`
|
|
132
|
+
: 'This turn failed before I could reply.';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Final chat text for a completed turn. Success keeps today's behaviour
|
|
138
|
+
* (result text, else the streamed text). A failure notice is appended to any
|
|
139
|
+
* partial output — partial text alone would read as a successful reply.
|
|
140
|
+
*/
|
|
141
|
+
export function composeClaudeFinalText(input) {
|
|
142
|
+
const primary = input.resultText?.trim() || input.streamedText?.trim() || null;
|
|
143
|
+
if (!input.failureNotice)
|
|
144
|
+
return primary;
|
|
145
|
+
return primary ? `${primary}\n\n${input.failureNotice}` : input.failureNotice;
|
|
146
|
+
}
|
|
147
|
+
// Model options are discovery-driven: the host publishes whatever the Claude
|
|
148
|
+
// Code CLI it spawns reports. A CLI older than this predates whole model
|
|
149
|
+
// families (2.1.220 is the first to know Opus 5), so an old binary silently
|
|
150
|
+
// shortens the picker instead of failing. Keep this at or above the CLI the
|
|
151
|
+
// pinned SDK bundles, so the bundled fallback always satisfies it.
|
|
152
|
+
export const MINIMUM_CLAUDE_CLI_VERSION = [2, 1, 220];
|
|
153
|
+
export function parseClaudeCliVersion(output) {
|
|
154
|
+
const match = output.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
155
|
+
return match ? match.slice(1, 4).map(Number) : null;
|
|
156
|
+
}
|
|
157
|
+
export function formatClaudeCliVersion(version) {
|
|
158
|
+
return version.join('.');
|
|
159
|
+
}
|
|
160
|
+
export function isSupportedClaudeCliVersion(installed, minimum = MINIMUM_CLAUDE_CLI_VERSION) {
|
|
161
|
+
for (let index = 0; index < minimum.length; index += 1) {
|
|
162
|
+
const part = installed[index] ?? 0;
|
|
163
|
+
if (part !== minimum[index])
|
|
164
|
+
return part > minimum[index];
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
63
168
|
function normalizeModelDescription(value) {
|
|
64
169
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
65
170
|
}
|
|
@@ -87,6 +192,31 @@ export function claudeModelInfoToOption(model) {
|
|
|
87
192
|
export function isClaudeCustomModelOption(option) {
|
|
88
193
|
return option?.description?.trim().toLowerCase() === 'custom model';
|
|
89
194
|
}
|
|
195
|
+
const CLAUDE_LONG_CONTEXT_SUFFIX = /\[1m\]$/;
|
|
196
|
+
const CLAUDE_FULL_MODEL_ID_FAMILY = /^claude-([a-z]+)-\d/;
|
|
197
|
+
/**
|
|
198
|
+
* Bare model aliases worth probing beyond what default discovery lists.
|
|
199
|
+
* Discovery decorates values (`opus[1m]`, `claude-fable-5[1m]`) while the
|
|
200
|
+
* plain alias (`opus`, `fable`) is selectable but unlisted; deriving the
|
|
201
|
+
* aliases from the discovered set keeps the picker discovery-driven instead
|
|
202
|
+
* of needing a code change every time a model family ships.
|
|
203
|
+
*/
|
|
204
|
+
export function deriveClaudeSupplementalModelProbes(discovered) {
|
|
205
|
+
const values = new Set(discovered.map((option) => option.value));
|
|
206
|
+
const aliases = new Set();
|
|
207
|
+
for (const option of discovered) {
|
|
208
|
+
if (option.value === 'default')
|
|
209
|
+
continue;
|
|
210
|
+
const base = option.value.replace(CLAUDE_LONG_CONTEXT_SUFFIX, '');
|
|
211
|
+
const alias = CLAUDE_FULL_MODEL_ID_FAMILY.exec(base)?.[1] ?? base;
|
|
212
|
+
if (!/^[a-z]+$/.test(alias))
|
|
213
|
+
continue;
|
|
214
|
+
if (alias === option.value || values.has(alias))
|
|
215
|
+
continue;
|
|
216
|
+
aliases.add(alias);
|
|
217
|
+
}
|
|
218
|
+
return [...aliases];
|
|
219
|
+
}
|
|
90
220
|
export function mergeClaudeDiscoveredModelOptions(...groups) {
|
|
91
221
|
const byValue = new Map();
|
|
92
222
|
const valueByLabel = new Map();
|
|
@@ -132,6 +262,8 @@ export function formatClaudeControlError(error) {
|
|
|
132
262
|
}
|
|
133
263
|
return 'Claude Code did not apply that control.';
|
|
134
264
|
}
|
|
265
|
+
// `Query.interrupt()` resolves to a receipt of what survived the interrupt
|
|
266
|
+
// (SDK 0.3.220+). Only settlement matters here, so accept any resolved value.
|
|
135
267
|
export async function confirmClaudeInterrupt(input) {
|
|
136
268
|
if (!input.active)
|
|
137
269
|
return 'confirmed';
|
package/dist/tool-policy.d.ts
CHANGED
|
@@ -32,6 +32,12 @@ export declare function decideClaudeToolPermissionForMode(input: {
|
|
|
32
32
|
approvalManager?: ApprovalManager | null;
|
|
33
33
|
canRequestApproval?: () => Promise<boolean>;
|
|
34
34
|
allowedNonOwnerTools?: Iterable<string>;
|
|
35
|
+
/**
|
|
36
|
+
* A user-configured `permissions.ask` rule forced this prompt (the SDK's
|
|
37
|
+
* `matchedAskRule`). The user asked to be consulted about this tool, which
|
|
38
|
+
* outranks the mode's host-side auto-approval.
|
|
39
|
+
*/
|
|
40
|
+
ruleForcedAsk?: boolean;
|
|
35
41
|
/**
|
|
36
42
|
* Lazy file-change preview for the approval card (Edit/Write-class tools).
|
|
37
43
|
* Invoked only once the call is headed for a responder approval; best-effort —
|
package/dist/tool-policy.js
CHANGED
|
@@ -2,6 +2,7 @@ const NON_OWNER_DENY_MESSAGE = 'Only the agent owner can authorize runtime tool
|
|
|
2
2
|
const NON_OWNER_CROSS_CONVERSATION_DENY_MESSAGE = 'Non-owner turns may only target the conversation they came from.';
|
|
3
3
|
const NATIVE_APPROVAL_UNAVAILABLE_MESSAGE = 'Runtime action approval is not available for this conversation.';
|
|
4
4
|
const NATIVE_APPROVAL_DENIED_MESSAGE = 'The user denied this action.';
|
|
5
|
+
const RULE_FORCED_INTERACTION_DENY_MESSAGE = 'A Claude permissions.ask rule cannot gate Canon interaction tools because they are already the human interaction surface. Remove or narrow the matching ask rule.';
|
|
5
6
|
export const CLAUDE_NATIVE_APPROVAL_PERMISSION_MODE = 'default';
|
|
6
7
|
const SENSITIVE_CLAUDE_TOOLS = new Set([
|
|
7
8
|
'agent',
|
|
@@ -63,6 +64,14 @@ const CANON_HITL_OR_READ_VERBS = new Set([
|
|
|
63
64
|
'list_contact_requests',
|
|
64
65
|
'list_conversations',
|
|
65
66
|
]);
|
|
67
|
+
// Asking permission to create or poll an interaction would put the interaction
|
|
68
|
+
// behind another interaction and can recurse indefinitely. Fail closed instead.
|
|
69
|
+
const CANON_INTERACTION_LIFECYCLE_VERBS = new Set([
|
|
70
|
+
'request_input',
|
|
71
|
+
'request_approval',
|
|
72
|
+
'check_approval',
|
|
73
|
+
'request_card',
|
|
74
|
+
]);
|
|
66
75
|
/**
|
|
67
76
|
* Verbs a NON-owner conversation member may trigger: same-conversation HITL
|
|
68
77
|
* and display surfaces, whose responder routing and owner-only escalation
|
|
@@ -205,8 +214,13 @@ export function buildClaudeApprovalDetails(toolName, toolInput) {
|
|
|
205
214
|
return details;
|
|
206
215
|
}
|
|
207
216
|
export async function decideClaudeToolPermissionForMode(input) {
|
|
217
|
+
let effectiveToolInput = input.toolInput;
|
|
218
|
+
let canonFastLaneAllowed = false;
|
|
208
219
|
const canonVerb = canonVerbFromToolName(input.toolName);
|
|
209
220
|
if (canonVerb) {
|
|
221
|
+
if (input.ruleForcedAsk && CANON_INTERACTION_LIFECYCLE_VERBS.has(canonVerb)) {
|
|
222
|
+
return { behavior: 'deny', message: RULE_FORCED_INTERACTION_DENY_MESSAGE };
|
|
223
|
+
}
|
|
210
224
|
// Non-owner turns: same-conversation HITL/display verbs are allowed
|
|
211
225
|
// (members may instruct the agent; the server enforces responder
|
|
212
226
|
// membership and owner-only escalation); everything cross-conversation
|
|
@@ -222,28 +236,42 @@ export async function decideClaudeToolPermissionForMode(input) {
|
|
|
222
236
|
if (typeof target === 'string' && target !== input.conversationId) {
|
|
223
237
|
return { behavior: 'deny', message: NON_OWNER_CROSS_CONVERSATION_DENY_MESSAGE };
|
|
224
238
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
239
|
+
effectiveToolInput = { ...input.toolInput, conversationId: input.conversationId };
|
|
240
|
+
canonFastLaneAllowed = true;
|
|
241
|
+
if (!input.ruleForcedAsk) {
|
|
242
|
+
return {
|
|
243
|
+
behavior: 'allow',
|
|
244
|
+
updatedInput: effectiveToolInput,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
return { behavior: 'deny', message: NON_OWNER_DENY_MESSAGE };
|
|
229
250
|
}
|
|
230
|
-
return { behavior: 'deny', message: NON_OWNER_DENY_MESSAGE };
|
|
231
251
|
}
|
|
232
252
|
// HITL + read verbs are allowed without an approval card: the HITL verbs
|
|
233
253
|
// themselves render the human-facing card (recursion otherwise), and the
|
|
234
254
|
// reads are side-effect free. Outbound verbs (send_to, share_contact)
|
|
235
255
|
// fall through to the mode policy — in native-approval mode they gate
|
|
236
256
|
// like any other side effect; in default mode owner turns allow.
|
|
237
|
-
if (CANON_HITL_OR_READ_VERBS.has(canonVerb)) {
|
|
257
|
+
if (CANON_HITL_OR_READ_VERBS.has(canonVerb) && !input.ruleForcedAsk) {
|
|
238
258
|
return { behavior: 'allow' };
|
|
239
259
|
}
|
|
260
|
+
canonFastLaneAllowed = CANON_HITL_OR_READ_VERBS.has(canonVerb);
|
|
240
261
|
}
|
|
241
262
|
if (input.permissionMode !== CLAUDE_NATIVE_APPROVAL_PERMISSION_MODE) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
263
|
+
const modeDecision = canonFastLaneAllowed
|
|
264
|
+
? { behavior: 'allow' }
|
|
265
|
+
: decideClaudeToolPermission({
|
|
266
|
+
toolName: input.toolName,
|
|
267
|
+
isOwnerTurn: input.isOwnerTurn,
|
|
268
|
+
allowedNonOwnerTools: input.allowedNonOwnerTools,
|
|
269
|
+
});
|
|
270
|
+
// A rule-forced ask only ever tightens: an auto-allow becomes a Canon
|
|
271
|
+
// approval card, while a non-owner deny still stands.
|
|
272
|
+
if (!input.ruleForcedAsk || modeDecision.behavior === 'deny') {
|
|
273
|
+
return modeDecision;
|
|
274
|
+
}
|
|
247
275
|
}
|
|
248
276
|
if (!input.approvalManager || !(await input.canRequestApproval?.())) {
|
|
249
277
|
return {
|
|
@@ -268,9 +296,9 @@ export async function decideClaudeToolPermissionForMode(input) {
|
|
|
268
296
|
}
|
|
269
297
|
let result;
|
|
270
298
|
try {
|
|
271
|
-
const risk = classifyClaudeApprovalRisk(input.toolName,
|
|
299
|
+
const risk = classifyClaudeApprovalRisk(input.toolName, effectiveToolInput);
|
|
272
300
|
const allowSessionRule = input.allowSessionRule ?? input.isOwnerTurn;
|
|
273
|
-
result = await input.approvalManager.requestApproval(input.conversationId, input.toolName,
|
|
301
|
+
result = await input.approvalManager.requestApproval(input.conversationId, input.toolName, effectiveToolInput, {
|
|
274
302
|
category: classifyClaudeApprovalCategory(input.toolName),
|
|
275
303
|
risk,
|
|
276
304
|
riskLevel: risk === 'destructive' ? 'destructive' : 'normal',
|
|
@@ -280,7 +308,7 @@ export async function decideClaudeToolPermissionForMode(input) {
|
|
|
280
308
|
runtime: 'claude-code',
|
|
281
309
|
method: 'canUseTool',
|
|
282
310
|
},
|
|
283
|
-
details: buildClaudeApprovalDetails(input.toolName,
|
|
311
|
+
details: buildClaudeApprovalDetails(input.toolName, effectiveToolInput),
|
|
284
312
|
...(diff ? { diff } : {}),
|
|
285
313
|
...(input.responseUserId ? { responseUserId: input.responseUserId } : {}),
|
|
286
314
|
ignoreSessionRules: !allowSessionRule,
|
|
@@ -294,7 +322,10 @@ export async function decideClaudeToolPermissionForMode(input) {
|
|
|
294
322
|
};
|
|
295
323
|
}
|
|
296
324
|
if (result.decision === 'allow') {
|
|
297
|
-
return {
|
|
325
|
+
return {
|
|
326
|
+
behavior: 'allow',
|
|
327
|
+
...(effectiveToolInput !== input.toolInput ? { updatedInput: effectiveToolInput } : {}),
|
|
328
|
+
};
|
|
298
329
|
}
|
|
299
330
|
return {
|
|
300
331
|
behavior: 'deny',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/claude-code-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
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,10 +30,10 @@
|
|
|
30
30
|
"test": "vitest run"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.
|
|
33
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
|
34
34
|
"@canonmsg/agent-sdk": "^7.1.1",
|
|
35
|
-
"@canonmsg/coding-agent-host": "^0.2.2",
|
|
36
35
|
"@canonmsg/agent-tools": "^0.3.1",
|
|
36
|
+
"@canonmsg/coding-agent-host": "^0.2.2",
|
|
37
37
|
"@canonmsg/core": "^8.0.0",
|
|
38
38
|
"@canonmsg/rich-cards": "^0.8.5",
|
|
39
39
|
"@modelcontextprotocol/sdk": "^1.29.0"
|