@canonmsg/claude-code-plugin 0.27.3 → 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/README.md +1 -1
- package/dist/host.js +137 -74
- package/dist/session-state.d.ts +78 -2
- package/dist/session-state.js +191 -1
- 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, 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) {
|
|
@@ -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;
|
|
@@ -315,47 +316,62 @@ function resolveClaudeCliPath() {
|
|
|
315
316
|
const override = process.env.CANON_CLAUDE_CLI_PATH?.trim();
|
|
316
317
|
if (override) {
|
|
317
318
|
if (existsSync(override)) {
|
|
318
|
-
|
|
319
|
-
cachedClaudeCliPath = override;
|
|
320
|
-
console.error(`[canon-host] claude CLI override: ${override}`);
|
|
321
|
-
return override;
|
|
319
|
+
return acceptClaudeCli('override', override);
|
|
322
320
|
}
|
|
323
321
|
console.error(`[canon-host] CANON_CLAUDE_CLI_PATH=${override} not found; ignoring`);
|
|
324
322
|
}
|
|
323
|
+
let resolved;
|
|
325
324
|
try {
|
|
326
|
-
const
|
|
325
|
+
const found = execFileSync('/bin/sh', ['-c', 'command -v claude'], {
|
|
327
326
|
encoding: 'utf8',
|
|
328
327
|
}).trim();
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
cachedClaudeCliPath = resolved;
|
|
332
|
-
console.error(`[canon-host] claude CLI on PATH: ${resolved}`);
|
|
333
|
-
return resolved;
|
|
328
|
+
if (found && existsSync(found)) {
|
|
329
|
+
resolved = found;
|
|
334
330
|
}
|
|
335
331
|
}
|
|
336
332
|
catch {
|
|
337
333
|
// `command -v` exits non-zero when claude isn't on PATH; fall through.
|
|
338
334
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
335
|
+
if (!resolved) {
|
|
336
|
+
cachedClaudeCliPath = null;
|
|
337
|
+
console.error('[canon-host] claude CLI not on PATH; using SDK-bundled binary');
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
return acceptClaudeCli('on PATH', resolved);
|
|
342
341
|
}
|
|
343
|
-
function
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
return
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
|
|
342
|
+
function readClaudeCliVersionSafely(cliPath) {
|
|
343
|
+
try {
|
|
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;
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
console.error(`[canon-host] Could not run ${cliPath} --version:`, error);
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
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;
|
|
358
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;
|
|
359
375
|
}
|
|
360
376
|
function toModelOptions(models) {
|
|
361
377
|
return models.map(claudeModelInfoToOption);
|
|
@@ -398,16 +414,13 @@ function resolveExecutionFallbackReason(environment) {
|
|
|
398
414
|
? null
|
|
399
415
|
: environment.reason;
|
|
400
416
|
}
|
|
401
|
-
const CLAUDE_SUPPLEMENTAL_MODEL_PROBES = ['opus', 'fable'];
|
|
402
417
|
async function detectRuntimeModels(cwd) {
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
detectSupplementalRuntimeModels(cwd),
|
|
406
|
-
]);
|
|
418
|
+
const discovered = await detectRuntimeModelsForSelection(cwd, 'default');
|
|
419
|
+
const supplemental = await detectSupplementalRuntimeModels(cwd, deriveClaudeSupplementalModelProbes(discovered));
|
|
407
420
|
return mergeClaudeDiscoveredModelOptions(discovered, ...supplemental);
|
|
408
421
|
}
|
|
409
|
-
async function detectSupplementalRuntimeModels(cwd) {
|
|
410
|
-
return Promise.all(
|
|
422
|
+
async function detectSupplementalRuntimeModels(cwd, aliases) {
|
|
423
|
+
return Promise.all(aliases.map(async (model) => {
|
|
411
424
|
try {
|
|
412
425
|
const options = await detectRuntimeModelsForSelection(cwd, model);
|
|
413
426
|
const option = options.find((entry) => entry.value === model);
|
|
@@ -769,6 +782,22 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
769
782
|
artifactRoutingMode,
|
|
770
783
|
}));
|
|
771
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
|
+
}
|
|
772
801
|
const inputStream = {
|
|
773
802
|
[Symbol.asyncIterator]() {
|
|
774
803
|
return {
|
|
@@ -778,12 +807,12 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
778
807
|
// Drain queued messages first (sent before iterator was ready)
|
|
779
808
|
if (messageQueue.length > 0) {
|
|
780
809
|
const next = messageQueue.shift();
|
|
781
|
-
|
|
810
|
+
bindActiveInput(next);
|
|
782
811
|
return Promise.resolve({ done: false, value: next.msg });
|
|
783
812
|
}
|
|
784
813
|
return new Promise((resolve) => {
|
|
785
814
|
resolveInput = (input) => {
|
|
786
|
-
|
|
815
|
+
bindActiveInput(input);
|
|
787
816
|
resolve({ done: false, value: input.msg });
|
|
788
817
|
};
|
|
789
818
|
});
|
|
@@ -1044,6 +1073,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1044
1073
|
approvalManager: config.approvalManager,
|
|
1045
1074
|
canRequestApproval: config.canRequestApproval,
|
|
1046
1075
|
allowedNonOwnerTools: allowedNonOwnerClaudeTools,
|
|
1076
|
+
ruleForcedAsk: Boolean(options.matchedAskRule),
|
|
1047
1077
|
// File-change preview for Edit/Write-class approvals: canUseTool fires
|
|
1048
1078
|
// before the mutation lands, so read the pre-image from disk and splice
|
|
1049
1079
|
// the edit in memory. Best-effort — failure logs and omits the diff.
|
|
@@ -1088,7 +1118,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1088
1118
|
streamingTimer: null,
|
|
1089
1119
|
idleResetTimer: null,
|
|
1090
1120
|
finalDeliveryTimer: null,
|
|
1091
|
-
seedResponseHandled: !!resumeSessionId, // Resumed sessions don't need seed handling
|
|
1092
1121
|
pendingInputs: [],
|
|
1093
1122
|
activeInput: null,
|
|
1094
1123
|
finalizedTurnKeys: new Set(),
|
|
@@ -1209,7 +1238,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1209
1238
|
resetTurnToIdle();
|
|
1210
1239
|
}, FINAL_MESSAGE_HANDOFF_MS);
|
|
1211
1240
|
}
|
|
1212
|
-
function markPendingFinalDelivery(finalText, turn) {
|
|
1241
|
+
function markPendingFinalDelivery(finalText, turn, suppressAutoReply = false) {
|
|
1213
1242
|
clearIdleResetTimer();
|
|
1214
1243
|
const retryCount = session.pendingFinalDelivery?.turnKey === turn.turnKey
|
|
1215
1244
|
? session.pendingFinalDelivery.retryCount
|
|
@@ -1219,6 +1248,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1219
1248
|
turnKey: turn.turnKey,
|
|
1220
1249
|
text: finalText,
|
|
1221
1250
|
retryCount,
|
|
1251
|
+
...(suppressAutoReply ? { suppressAutoReply: true } : {}),
|
|
1222
1252
|
messageId: buildClaudeFinalMessageId({
|
|
1223
1253
|
agentId,
|
|
1224
1254
|
conversationId,
|
|
@@ -1233,7 +1263,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1233
1263
|
writeTurn();
|
|
1234
1264
|
typingSignals.start(conversationId, 'typing').catch(() => { });
|
|
1235
1265
|
}
|
|
1236
|
-
async function deliverFinalReply(finalText, turn = session.activeInput) {
|
|
1266
|
+
async function deliverFinalReply(finalText, turn = session.activeInput, suppressAutoReply = false) {
|
|
1237
1267
|
if (!shouldDeliverClaudeFinal({
|
|
1238
1268
|
turn,
|
|
1239
1269
|
finalizedTurnKeys: session.finalizedTurnKeys,
|
|
@@ -1255,16 +1285,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1255
1285
|
...(session.activeSelfContextId
|
|
1256
1286
|
? { selfContextId: session.activeSelfContextId }
|
|
1257
1287
|
: {}),
|
|
1258
|
-
metadata: {
|
|
1288
|
+
metadata: buildClaudeFinalTurnMetadata({
|
|
1259
1289
|
turnId: session.currentTurnId,
|
|
1260
1290
|
turnKey: deliverTurn.turnKey,
|
|
1261
|
-
sourceMessageId: deliverTurn.sourceMessageId
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
: {}),
|
|
1267
|
-
},
|
|
1291
|
+
sourceMessageId: deliverTurn.sourceMessageId,
|
|
1292
|
+
deliveryIntent: session.lastAcceptedIntent,
|
|
1293
|
+
turnTrail: getFinalTurnTrail(),
|
|
1294
|
+
suppressAutoReply,
|
|
1295
|
+
}),
|
|
1268
1296
|
});
|
|
1269
1297
|
session.finalizedTurnKeys.add(deliverTurn.turnKey);
|
|
1270
1298
|
markInputCompleted(deliverTurn);
|
|
@@ -1276,7 +1304,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1276
1304
|
return true;
|
|
1277
1305
|
}
|
|
1278
1306
|
catch (err) {
|
|
1279
|
-
markPendingFinalDelivery(finalText, deliverTurn);
|
|
1307
|
+
markPendingFinalDelivery(finalText, deliverTurn, suppressAutoReply);
|
|
1280
1308
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
|
|
1281
1309
|
return false;
|
|
1282
1310
|
}
|
|
@@ -1336,7 +1364,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1336
1364
|
const retryTurn = session.activeInput?.turnKey === pending.turnKey
|
|
1337
1365
|
? session.activeInput
|
|
1338
1366
|
: null;
|
|
1339
|
-
void deliverFinalReply(pending.text, retryTurn).then((sent) => {
|
|
1367
|
+
void deliverFinalReply(pending.text, retryTurn, pending.suppressAutoReply ?? false).then((sent) => {
|
|
1340
1368
|
if (sent) {
|
|
1341
1369
|
scheduleFinalHandoffReset();
|
|
1342
1370
|
return;
|
|
@@ -1549,10 +1577,13 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1549
1577
|
const assistantSessionId = msg.session_id;
|
|
1550
1578
|
if (assistantSessionId)
|
|
1551
1579
|
session.sdkSessionId = assistantSessionId;
|
|
1552
|
-
|
|
1553
|
-
|
|
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))
|
|
1554
1586
|
break;
|
|
1555
|
-
}
|
|
1556
1587
|
const textBlocks = (msg.message?.content ?? [])
|
|
1557
1588
|
.filter((b) => b.type === 'text')
|
|
1558
1589
|
.map((b) => b.text);
|
|
@@ -1641,7 +1672,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1641
1672
|
const resultSessionId = msg.session_id;
|
|
1642
1673
|
if (resultSessionId)
|
|
1643
1674
|
session.sdkSessionId = resultSessionId;
|
|
1644
|
-
|
|
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;
|
|
1645
1679
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
|
|
1646
1680
|
// Turn artifacts land before the final text reply.
|
|
1647
1681
|
if (completedInput?.kind === 'canon'
|
|
@@ -1651,7 +1685,22 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1651
1685
|
const resultText = typeof msg.result === 'string'
|
|
1652
1686
|
? msg.result.trim()
|
|
1653
1687
|
: '';
|
|
1654
|
-
|
|
1688
|
+
// Error results carry no `result` text — without a notice the
|
|
1689
|
+
// turn would end silently and the agent just goes idle in chat.
|
|
1690
|
+
const failureNotice = buildClaudeTurnFailureNotice({
|
|
1691
|
+
subtype: msg.subtype,
|
|
1692
|
+
errors: msg.errors,
|
|
1693
|
+
});
|
|
1694
|
+
if (failureNotice) {
|
|
1695
|
+
const errorList = Array.isArray(msg.errors) ? msg.errors : [];
|
|
1696
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn failed (${msg.subtype}): `
|
|
1697
|
+
+ (errorList.join(' | ') || 'no error detail'));
|
|
1698
|
+
}
|
|
1699
|
+
const finalText = composeClaudeFinalText({
|
|
1700
|
+
resultText,
|
|
1701
|
+
streamedText: session.pendingFinalText,
|
|
1702
|
+
failureNotice,
|
|
1703
|
+
});
|
|
1655
1704
|
const shouldDeliverFinal = finalText
|
|
1656
1705
|
? shouldDeliverClaudeFinal({
|
|
1657
1706
|
turn: completedInput,
|
|
@@ -1659,8 +1708,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1659
1708
|
interruptedTurnKeys: session.interruptedTurnKeys,
|
|
1660
1709
|
})
|
|
1661
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
|
+
}
|
|
1662
1724
|
const finalDelivered = finalText && shouldDeliverFinal
|
|
1663
|
-
? await deliverFinalReply(finalText, completedInput)
|
|
1725
|
+
? await deliverFinalReply(finalText, completedInput, Boolean(failureNotice))
|
|
1664
1726
|
: true;
|
|
1665
1727
|
try {
|
|
1666
1728
|
const usage = await q.getContextUsage();
|
|
@@ -1687,6 +1749,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1687
1749
|
scheduleFinalHandoffReset();
|
|
1688
1750
|
}
|
|
1689
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(() => { });
|
|
1690
1756
|
resetTurnToIdle();
|
|
1691
1757
|
}
|
|
1692
1758
|
}
|
|
@@ -1740,26 +1806,13 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1740
1806
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial ultracode:`, err);
|
|
1741
1807
|
}
|
|
1742
1808
|
}
|
|
1743
|
-
if (!resumeSessionId) {
|
|
1744
|
-
// New session — send seed message to activate streaming input mode
|
|
1745
|
-
sendInput(createClaudeInputEnvelope({
|
|
1746
|
-
kind: 'seed',
|
|
1747
|
-
msg: {
|
|
1748
|
-
type: 'user',
|
|
1749
|
-
message: {
|
|
1750
|
-
role: 'user',
|
|
1751
|
-
content: 'You are assisting in an ongoing chat. Reply naturally to the latest participant.',
|
|
1752
|
-
},
|
|
1753
|
-
parent_tool_use_id: null,
|
|
1754
|
-
},
|
|
1755
|
-
}));
|
|
1756
|
-
}
|
|
1757
1809
|
// Refresh the canonical runtime descriptor after Claude reports supported models.
|
|
1758
1810
|
try {
|
|
1759
1811
|
const models = await q.supportedModels();
|
|
1760
|
-
const
|
|
1812
|
+
const discoveredOptions = toModelOptions(models);
|
|
1813
|
+
const supplemental = await detectSupplementalRuntimeModels(cwd, deriveClaudeSupplementalModelProbes(discoveredOptions));
|
|
1761
1814
|
const modelList = resolveClaudeModelOptions({
|
|
1762
|
-
discovered: mergeClaudeDiscoveredModelOptions(
|
|
1815
|
+
discovered: mergeClaudeDiscoveredModelOptions(discoveredOptions, ...supplemental),
|
|
1763
1816
|
selectedModel: session.state.model,
|
|
1764
1817
|
});
|
|
1765
1818
|
session.availableModels = modelList;
|
|
@@ -2539,6 +2592,11 @@ export async function main() {
|
|
|
2539
2592
|
content: messageContent,
|
|
2540
2593
|
},
|
|
2541
2594
|
parent_tool_use_id: null,
|
|
2595
|
+
origin: claudeOriginForCanonSender({
|
|
2596
|
+
senderType: m.senderType,
|
|
2597
|
+
senderId: m.senderId,
|
|
2598
|
+
senderName: m.senderName,
|
|
2599
|
+
}),
|
|
2542
2600
|
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
|
|
2543
2601
|
return 'queued';
|
|
2544
2602
|
}
|
|
@@ -2790,6 +2848,11 @@ export async function main() {
|
|
|
2790
2848
|
content: messageContent,
|
|
2791
2849
|
},
|
|
2792
2850
|
parent_tool_use_id: null,
|
|
2851
|
+
origin: claudeOriginForCanonSender({
|
|
2852
|
+
senderType: m.senderType,
|
|
2853
|
+
senderId: m.senderId,
|
|
2854
|
+
senderName: m.senderName,
|
|
2855
|
+
}),
|
|
2793
2856
|
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
|
|
2794
2857
|
return true;
|
|
2795
2858
|
})().then((queued) => {
|
package/dist/session-state.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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';
|
|
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;
|
|
@@ -25,6 +27,8 @@ export interface ClaudePendingFinalDelivery {
|
|
|
25
27
|
text: string;
|
|
26
28
|
messageId: string;
|
|
27
29
|
retryCount: number;
|
|
30
|
+
/** Carried so a retried failure notice keeps suppressing agent auto-replies. */
|
|
31
|
+
suppressAutoReply?: boolean;
|
|
28
32
|
}
|
|
29
33
|
export interface ClaudeCompletedTurnState {
|
|
30
34
|
state: {
|
|
@@ -66,7 +70,71 @@ export declare function shouldDeliverClaudeFinal(input: {
|
|
|
66
70
|
finalizedTurnKeys: ReadonlySet<string>;
|
|
67
71
|
interruptedTurnKeys: ReadonlySet<string>;
|
|
68
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;
|
|
69
95
|
export declare function resetClaudeCompletedTurnState(session: ClaudeCompletedTurnState): void;
|
|
96
|
+
export declare function claudeOriginForCanonSender(input: {
|
|
97
|
+
senderType?: string | null;
|
|
98
|
+
senderId: string;
|
|
99
|
+
senderName?: string | null;
|
|
100
|
+
}): SDKMessageOrigin;
|
|
101
|
+
/**
|
|
102
|
+
* Metadata for a completed-turn message. `suppressAutoReply` is set for turns
|
|
103
|
+
* that ended in failure: a failure notice is not a handoff, so it must not
|
|
104
|
+
* trigger a peer agent's auto-reply and start an error-response exchange.
|
|
105
|
+
*/
|
|
106
|
+
export declare function buildClaudeFinalTurnMetadata(input: {
|
|
107
|
+
turnId?: string | null;
|
|
108
|
+
turnKey: string;
|
|
109
|
+
sourceMessageId?: string | null;
|
|
110
|
+
deliveryIntent?: DeliveryIntent | null;
|
|
111
|
+
turnTrail?: ReadonlyArray<unknown>;
|
|
112
|
+
suppressAutoReply?: boolean;
|
|
113
|
+
}): Record<string, unknown>;
|
|
114
|
+
/**
|
|
115
|
+
* A chat-deliverable notice for a turn that ended on an SDK error result.
|
|
116
|
+
* Without it the agent goes from working to idle with no message at all —
|
|
117
|
+
* the error subtypes carry no `result` text, and `errors` was never read.
|
|
118
|
+
* Returns null for success results.
|
|
119
|
+
*/
|
|
120
|
+
export declare function buildClaudeTurnFailureNotice(input: {
|
|
121
|
+
subtype: unknown;
|
|
122
|
+
errors?: unknown;
|
|
123
|
+
}): string | null;
|
|
124
|
+
/**
|
|
125
|
+
* Final chat text for a completed turn. Success keeps today's behaviour
|
|
126
|
+
* (result text, else the streamed text). A failure notice is appended to any
|
|
127
|
+
* partial output — partial text alone would read as a successful reply.
|
|
128
|
+
*/
|
|
129
|
+
export declare function composeClaudeFinalText(input: {
|
|
130
|
+
resultText?: string | null;
|
|
131
|
+
streamedText?: string | null;
|
|
132
|
+
failureNotice?: string | null;
|
|
133
|
+
}): string | null;
|
|
134
|
+
export declare const MINIMUM_CLAUDE_CLI_VERSION: readonly number[];
|
|
135
|
+
export declare function parseClaudeCliVersion(output: string): number[] | null;
|
|
136
|
+
export declare function formatClaudeCliVersion(version: readonly number[]): string;
|
|
137
|
+
export declare function isSupportedClaudeCliVersion(installed: readonly number[], minimum?: readonly number[]): boolean;
|
|
70
138
|
export interface ClaudeModelInfoLike {
|
|
71
139
|
value: string;
|
|
72
140
|
displayName: string;
|
|
@@ -74,6 +142,14 @@ export interface ClaudeModelInfoLike {
|
|
|
74
142
|
}
|
|
75
143
|
export declare function claudeModelInfoToOption(model: ClaudeModelInfoLike): ModelOption;
|
|
76
144
|
export declare function isClaudeCustomModelOption(option: ModelOption | null | undefined): boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Bare model aliases worth probing beyond what default discovery lists.
|
|
147
|
+
* Discovery decorates values (`opus[1m]`, `claude-fable-5[1m]`) while the
|
|
148
|
+
* plain alias (`opus`, `fable`) is selectable but unlisted; deriving the
|
|
149
|
+
* aliases from the discovered set keeps the picker discovery-driven instead
|
|
150
|
+
* of needing a code change every time a model family ships.
|
|
151
|
+
*/
|
|
152
|
+
export declare function deriveClaudeSupplementalModelProbes(discovered: ReadonlyArray<ModelOption>): string[];
|
|
77
153
|
export declare function mergeClaudeDiscoveredModelOptions(...groups: ReadonlyArray<ReadonlyArray<ModelOption>>): ModelOption[];
|
|
78
154
|
export declare function resolveClaudeModelOptions(input: {
|
|
79
155
|
discovered: ReadonlyArray<ModelOption>;
|
|
@@ -82,7 +158,7 @@ export declare function resolveClaudeModelOptions(input: {
|
|
|
82
158
|
export declare function formatClaudeControlError(error: unknown): string;
|
|
83
159
|
export declare function confirmClaudeInterrupt(input: {
|
|
84
160
|
active: boolean;
|
|
85
|
-
interrupt: () => Promise<
|
|
161
|
+
interrupt: () => Promise<unknown>;
|
|
86
162
|
}): Promise<'confirmed' | 'defer'>;
|
|
87
163
|
/** Requested session-control values from the control plane (raw, pre-validation). */
|
|
88
164
|
export interface ClaudeSessionControlInput {
|
package/dist/session-state.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
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();
|
|
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();
|
|
5
10
|
return {
|
|
6
11
|
kind: input.kind,
|
|
7
|
-
|
|
12
|
+
messageUuid,
|
|
13
|
+
msg: { ...input.msg, uuid: messageUuid },
|
|
8
14
|
intent: input.intent ?? 'queue',
|
|
9
15
|
sourceMessageId,
|
|
10
16
|
markAccepted: Boolean(input.markAccepted),
|
|
@@ -48,6 +54,59 @@ export function shouldDeliverClaudeFinal(input) {
|
|
|
48
54
|
return false;
|
|
49
55
|
return true;
|
|
50
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
|
+
}
|
|
51
110
|
export function resetClaudeCompletedTurnState(session) {
|
|
52
111
|
session.state.state = 'idle';
|
|
53
112
|
session.currentTurnId = null;
|
|
@@ -60,6 +119,110 @@ export function resetClaudeCompletedTurnState(session) {
|
|
|
60
119
|
session.toolInProgress = false;
|
|
61
120
|
session.turnState = 'idle';
|
|
62
121
|
}
|
|
122
|
+
// Provenance for a Canon message entering the SDK. From 0.3.220 an absent
|
|
123
|
+
// `origin` is "unattributed" and fails closed at strict isHuman() gates, so a
|
|
124
|
+
// human sender must be stamped explicitly rather than left to default. Agent
|
|
125
|
+
// senders keep their identity as peers instead of being laundered into humans.
|
|
126
|
+
export function claudeOriginForCanonSender(input) {
|
|
127
|
+
if (input.senderType === 'human')
|
|
128
|
+
return { kind: 'human' };
|
|
129
|
+
const name = input.senderName?.trim();
|
|
130
|
+
return { kind: 'peer', from: input.senderId, ...(name ? { name } : {}) };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Metadata for a completed-turn message. `suppressAutoReply` is set for turns
|
|
134
|
+
* that ended in failure: a failure notice is not a handoff, so it must not
|
|
135
|
+
* trigger a peer agent's auto-reply and start an error-response exchange.
|
|
136
|
+
*/
|
|
137
|
+
export function buildClaudeFinalTurnMetadata(input) {
|
|
138
|
+
return {
|
|
139
|
+
turnId: input.turnId ?? null,
|
|
140
|
+
turnKey: input.turnKey,
|
|
141
|
+
sourceMessageId: input.sourceMessageId ?? undefined,
|
|
142
|
+
turnSemantics: 'turn_complete',
|
|
143
|
+
...(input.suppressAutoReply ? { replyBehavior: 'suppress_auto_reply' } : {}),
|
|
144
|
+
deliveryIntent: input.deliveryIntent ?? undefined,
|
|
145
|
+
...(input.turnTrail && input.turnTrail.length > 0 ? { turnTrail: input.turnTrail } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const CLAUDE_FAILURE_DETAIL_MAX_CHARS = 280;
|
|
149
|
+
function claudeFailureDetail(errors) {
|
|
150
|
+
if (!Array.isArray(errors))
|
|
151
|
+
return null;
|
|
152
|
+
const first = errors.find((entry) => typeof entry === 'string' && entry.trim());
|
|
153
|
+
if (typeof first !== 'string')
|
|
154
|
+
return null;
|
|
155
|
+
const trimmed = first.trim();
|
|
156
|
+
return trimmed.length > CLAUDE_FAILURE_DETAIL_MAX_CHARS
|
|
157
|
+
? `${trimmed.slice(0, CLAUDE_FAILURE_DETAIL_MAX_CHARS - 1)}…`
|
|
158
|
+
: trimmed;
|
|
159
|
+
}
|
|
160
|
+
function claudeUsageLimitDetail(errors) {
|
|
161
|
+
if (!Array.isArray(errors))
|
|
162
|
+
return null;
|
|
163
|
+
const match = errors.find((entry) => typeof entry === 'string'
|
|
164
|
+
&& USAGE_LIMIT_ERROR_PREFIXES.some((prefix) => entry.startsWith(prefix)));
|
|
165
|
+
return typeof match === 'string' ? claudeFailureDetail([match]) : null;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* A chat-deliverable notice for a turn that ended on an SDK error result.
|
|
169
|
+
* Without it the agent goes from working to idle with no message at all —
|
|
170
|
+
* the error subtypes carry no `result` text, and `errors` was never read.
|
|
171
|
+
* Returns null for success results.
|
|
172
|
+
*/
|
|
173
|
+
export function buildClaudeTurnFailureNotice(input) {
|
|
174
|
+
if (typeof input.subtype !== 'string' || !input.subtype.startsWith('error')) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const usageLimit = claudeUsageLimitDetail(input.errors);
|
|
178
|
+
if (usageLimit) {
|
|
179
|
+
return `I've hit a usage limit, so this turn didn't finish: ${usageLimit}`;
|
|
180
|
+
}
|
|
181
|
+
switch (input.subtype) {
|
|
182
|
+
case 'error_max_turns':
|
|
183
|
+
return 'I stopped this turn after reaching its maximum number of turns. Send a follow-up message to continue.';
|
|
184
|
+
case 'error_max_budget_usd':
|
|
185
|
+
return 'I stopped this turn after reaching its spending budget.';
|
|
186
|
+
default: {
|
|
187
|
+
const detail = claudeFailureDetail(input.errors);
|
|
188
|
+
return detail
|
|
189
|
+
? `This turn failed before I could reply: ${detail}`
|
|
190
|
+
: 'This turn failed before I could reply.';
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Final chat text for a completed turn. Success keeps today's behaviour
|
|
196
|
+
* (result text, else the streamed text). A failure notice is appended to any
|
|
197
|
+
* partial output — partial text alone would read as a successful reply.
|
|
198
|
+
*/
|
|
199
|
+
export function composeClaudeFinalText(input) {
|
|
200
|
+
const primary = input.resultText?.trim() || input.streamedText?.trim() || null;
|
|
201
|
+
if (!input.failureNotice)
|
|
202
|
+
return primary;
|
|
203
|
+
return primary ? `${primary}\n\n${input.failureNotice}` : input.failureNotice;
|
|
204
|
+
}
|
|
205
|
+
// Model options are discovery-driven: the host publishes whatever the Claude
|
|
206
|
+
// Code CLI it spawns reports. A CLI older than this predates whole model
|
|
207
|
+
// families (2.1.220 is the first to know Opus 5), so an old binary silently
|
|
208
|
+
// shortens the picker instead of failing. Keep this at or above the CLI the
|
|
209
|
+
// pinned SDK bundles, so the bundled fallback always satisfies it.
|
|
210
|
+
export const MINIMUM_CLAUDE_CLI_VERSION = [2, 1, 220];
|
|
211
|
+
export function parseClaudeCliVersion(output) {
|
|
212
|
+
const match = output.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
213
|
+
return match ? match.slice(1, 4).map(Number) : null;
|
|
214
|
+
}
|
|
215
|
+
export function formatClaudeCliVersion(version) {
|
|
216
|
+
return version.join('.');
|
|
217
|
+
}
|
|
218
|
+
export function isSupportedClaudeCliVersion(installed, minimum = MINIMUM_CLAUDE_CLI_VERSION) {
|
|
219
|
+
for (let index = 0; index < minimum.length; index += 1) {
|
|
220
|
+
const part = installed[index] ?? 0;
|
|
221
|
+
if (part !== minimum[index])
|
|
222
|
+
return part > minimum[index];
|
|
223
|
+
}
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
63
226
|
function normalizeModelDescription(value) {
|
|
64
227
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
65
228
|
}
|
|
@@ -87,6 +250,31 @@ export function claudeModelInfoToOption(model) {
|
|
|
87
250
|
export function isClaudeCustomModelOption(option) {
|
|
88
251
|
return option?.description?.trim().toLowerCase() === 'custom model';
|
|
89
252
|
}
|
|
253
|
+
const CLAUDE_LONG_CONTEXT_SUFFIX = /\[1m\]$/;
|
|
254
|
+
const CLAUDE_FULL_MODEL_ID_FAMILY = /^claude-([a-z]+)-\d/;
|
|
255
|
+
/**
|
|
256
|
+
* Bare model aliases worth probing beyond what default discovery lists.
|
|
257
|
+
* Discovery decorates values (`opus[1m]`, `claude-fable-5[1m]`) while the
|
|
258
|
+
* plain alias (`opus`, `fable`) is selectable but unlisted; deriving the
|
|
259
|
+
* aliases from the discovered set keeps the picker discovery-driven instead
|
|
260
|
+
* of needing a code change every time a model family ships.
|
|
261
|
+
*/
|
|
262
|
+
export function deriveClaudeSupplementalModelProbes(discovered) {
|
|
263
|
+
const values = new Set(discovered.map((option) => option.value));
|
|
264
|
+
const aliases = new Set();
|
|
265
|
+
for (const option of discovered) {
|
|
266
|
+
if (option.value === 'default')
|
|
267
|
+
continue;
|
|
268
|
+
const base = option.value.replace(CLAUDE_LONG_CONTEXT_SUFFIX, '');
|
|
269
|
+
const alias = CLAUDE_FULL_MODEL_ID_FAMILY.exec(base)?.[1] ?? base;
|
|
270
|
+
if (!/^[a-z]+$/.test(alias))
|
|
271
|
+
continue;
|
|
272
|
+
if (alias === option.value || values.has(alias))
|
|
273
|
+
continue;
|
|
274
|
+
aliases.add(alias);
|
|
275
|
+
}
|
|
276
|
+
return [...aliases];
|
|
277
|
+
}
|
|
90
278
|
export function mergeClaudeDiscoveredModelOptions(...groups) {
|
|
91
279
|
const byValue = new Map();
|
|
92
280
|
const valueByLabel = new Map();
|
|
@@ -132,6 +320,8 @@ export function formatClaudeControlError(error) {
|
|
|
132
320
|
}
|
|
133
321
|
return 'Claude Code did not apply that control.';
|
|
134
322
|
}
|
|
323
|
+
// `Query.interrupt()` resolves to a receipt of what survived the interrupt
|
|
324
|
+
// (SDK 0.3.220+). Only settlement matters here, so accept any resolved value.
|
|
135
325
|
export async function confirmClaudeInterrupt(input) {
|
|
136
326
|
if (!input.active)
|
|
137
327
|
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.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",
|
|
@@ -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"
|