@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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "Canon",
3
3
  "description": "Connect Claude Code to Canon — messaging where AI agents are first-class citizens",
4
- "version": "0.27.3",
4
+ "version": "0.28.1",
5
5
  "channels": [
6
6
  {
7
7
  "server": "canon-channel",
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.201 or newer.
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
- assertExternalClaudeCliVersion(override);
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 resolved = execFileSync('/bin/sh', ['-c', 'command -v claude'], {
325
+ const found = execFileSync('/bin/sh', ['-c', 'command -v claude'], {
327
326
  encoding: 'utf8',
328
327
  }).trim();
329
- if (resolved && existsSync(resolved)) {
330
- assertExternalClaudeCliVersion(resolved);
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
- cachedClaudeCliPath = null;
340
- console.error('[canon-host] claude CLI not on PATH; using SDK-bundled binary');
341
- return undefined;
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 assertExternalClaudeCliVersion(cliPath) {
344
- const output = execFileSync(cliPath, ['--version'], { encoding: 'utf8' }).trim();
345
- const match = output.match(/^(\d+)\.(\d+)\.(\d+)/);
346
- if (!match) {
347
- throw new Error(`Could not determine Claude Code version from: ${output}`);
348
- }
349
- const installed = match.slice(1, 4).map(Number);
350
- const minimum = [2, 1, 201];
351
- const supported = installed.some((value, index) => {
352
- if (value === minimum[index])
353
- return false;
354
- return value > minimum[index] && installed.slice(0, index).every((part, partIndex) => part === minimum[partIndex]);
355
- }) || installed.every((value, index) => value === minimum[index]);
356
- if (!supported) {
357
- throw new Error(`Claude Code ${minimum.join('.')} or newer is required; found ${match[0]}.`);
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 [discovered, supplemental] = await Promise.all([
404
- detectRuntimeModelsForSelection(cwd, 'default'),
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(CLAUDE_SUPPLEMENTAL_MODEL_PROBES.map(async (model) => {
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
- session.activeInput = next;
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
- session.activeInput = input;
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 ?? undefined,
1262
- turnSemantics: 'turn_complete',
1263
- deliveryIntent: session.lastAcceptedIntent ?? undefined,
1264
- ...(getFinalTurnTrail().length > 0
1265
- ? { turnTrail: getFinalTurnTrail() }
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
- if (!session.seedResponseHandled) {
1553
- session.seedResponseHandled = true;
1580
+ // Only a Canon turn's text is user-facing. This used to skip the
1581
+ // FIRST assistant message on the assumption it answered the seed,
1582
+ // but the seed is pulled after the first real message, so the guard
1583
+ // swallowed the actual reply — no streamed text and no fallback for
1584
+ // the final. Gate on the owning turn instead of on arrival order.
1585
+ if (!claudeInputOwnsTurnSlot(session.activeInput))
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
- const completedInput = session.activeInput;
1675
+ // Match the result to the envelope that produced it. Falling back to
1676
+ // the slot only when the SDK reports no uuid keeps older runtimes
1677
+ // working, at their existing accuracy.
1678
+ const completedInput = takeClaudeResultOwner(dispatchedInputs, msg.user_message_uuid) ?? session.activeInput;
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
- const finalText = resultText || session.pendingFinalText?.trim() || null;
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 supplemental = await detectSupplementalRuntimeModels(cwd);
1812
+ const discoveredOptions = toModelOptions(models);
1813
+ const supplemental = await detectSupplementalRuntimeModels(cwd, deriveClaudeSupplementalModelProbes(discoveredOptions));
1761
1814
  const modelList = resolveClaudeModelOptions({
1762
- discovered: mergeClaudeDiscoveredModelOptions(toModelOptions(models), ...supplemental),
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) => {
@@ -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<void>;
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 {
@@ -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
- msg: input.msg,
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';
@@ -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 —
@@ -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
- return {
226
- behavior: 'allow',
227
- updatedInput: { ...input.toolInput, conversationId: input.conversationId },
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
- return decideClaudeToolPermission({
243
- toolName: input.toolName,
244
- isOwnerTurn: input.isOwnerTurn,
245
- allowedNonOwnerTools: input.allowedNonOwnerTools,
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, input.toolInput);
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, input.toolInput, {
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, input.toolInput),
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 { behavior: 'allow' };
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.27.3",
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.204",
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"