@canonmsg/claude-code-plugin 0.27.2 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.2",
4
+ "version": "0.28.0",
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, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeFinalText, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
38
38
  import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } from './user-dialog.js';
39
39
  import { collectMissedInboundMessages, createReconnectRecoveryGate, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
40
40
  function parseRuntimeVisibilityPreset(value) {
@@ -307,7 +307,8 @@ let cachedClaudeCliPath;
307
307
  // claude-agent-sdk-darwin-arm64. On some macOS setups that exact binary gets
308
308
  // SIGKILL'd at launch (path-based code-sign enforcement) even though a
309
309
  // byte-identical copy at ~/.local/bin/claude runs fine. Prefer whatever is on
310
- // PATH; fall back to the bundled one.
310
+ // PATH; fall back to the bundled one — including when the PATH copy is older
311
+ // than MINIMUM_CLAUDE_CLI_VERSION, since the bundle is always new enough.
311
312
  function resolveClaudeCliPath() {
312
313
  if (cachedClaudeCliPath !== undefined) {
313
314
  return cachedClaudeCliPath ?? undefined;
@@ -322,39 +323,57 @@ function resolveClaudeCliPath() {
322
323
  }
323
324
  console.error(`[canon-host] CANON_CLAUDE_CLI_PATH=${override} not found; ignoring`);
324
325
  }
326
+ let resolved;
325
327
  try {
326
- const resolved = execFileSync('/bin/sh', ['-c', 'command -v claude'], {
328
+ const found = execFileSync('/bin/sh', ['-c', 'command -v claude'], {
327
329
  encoding: 'utf8',
328
330
  }).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;
331
+ if (found && existsSync(found)) {
332
+ resolved = found;
334
333
  }
335
334
  }
336
335
  catch {
337
336
  // `command -v` exits non-zero when claude isn't on PATH; fall through.
338
337
  }
339
- cachedClaudeCliPath = null;
340
- console.error('[canon-host] claude CLI not on PATH; using SDK-bundled binary');
341
- return undefined;
338
+ if (!resolved) {
339
+ cachedClaudeCliPath = null;
340
+ console.error('[canon-host] claude CLI not on PATH; using SDK-bundled binary');
341
+ return undefined;
342
+ }
343
+ // A stale CLI on PATH would otherwise shadow the newer bundled one and quietly
344
+ // drop model families from discovery. Prefer the bundle over a silent downgrade.
345
+ let version = null;
346
+ try {
347
+ version = readExternalClaudeCliVersion(resolved);
348
+ }
349
+ catch (error) {
350
+ console.error(`[canon-host] Could not read the version of the claude CLI at ${resolved}:`, error);
351
+ }
352
+ if (!version || !isSupportedClaudeCliVersion(version)) {
353
+ cachedClaudeCliPath = null;
354
+ const found = version ? `is ${formatClaudeCliVersion(version)}` : 'has an unreadable version';
355
+ console.error(`[canon-host] claude CLI on PATH (${resolved}) ${found}; `
356
+ + `${formatClaudeCliVersion(MINIMUM_CLAUDE_CLI_VERSION)} or newer is required — `
357
+ + 'using the SDK-bundled binary instead. Run `claude update` to use your own install.');
358
+ return undefined;
359
+ }
360
+ cachedClaudeCliPath = resolved;
361
+ console.error(`[canon-host] claude CLI on PATH: ${resolved} (${formatClaudeCliVersion(version)})`);
362
+ return resolved;
342
363
  }
343
- function assertExternalClaudeCliVersion(cliPath) {
364
+ function readExternalClaudeCliVersion(cliPath) {
344
365
  const output = execFileSync(cliPath, ['--version'], { encoding: 'utf8' }).trim();
345
- const match = output.match(/^(\d+)\.(\d+)\.(\d+)/);
346
- if (!match) {
366
+ const version = parseClaudeCliVersion(output);
367
+ if (!version) {
347
368
  throw new Error(`Could not determine Claude Code version from: ${output}`);
348
369
  }
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]}.`);
370
+ return version;
371
+ }
372
+ function assertExternalClaudeCliVersion(cliPath) {
373
+ const version = readExternalClaudeCliVersion(cliPath);
374
+ if (!isSupportedClaudeCliVersion(version)) {
375
+ throw new Error(`Claude Code ${formatClaudeCliVersion(MINIMUM_CLAUDE_CLI_VERSION)} or newer is required; `
376
+ + `found ${formatClaudeCliVersion(version)}.`);
358
377
  }
359
378
  }
360
379
  function toModelOptions(models) {
@@ -398,16 +417,13 @@ function resolveExecutionFallbackReason(environment) {
398
417
  ? null
399
418
  : environment.reason;
400
419
  }
401
- const CLAUDE_SUPPLEMENTAL_MODEL_PROBES = ['opus', 'fable'];
402
420
  async function detectRuntimeModels(cwd) {
403
- const [discovered, supplemental] = await Promise.all([
404
- detectRuntimeModelsForSelection(cwd, 'default'),
405
- detectSupplementalRuntimeModels(cwd),
406
- ]);
421
+ const discovered = await detectRuntimeModelsForSelection(cwd, 'default');
422
+ const supplemental = await detectSupplementalRuntimeModels(cwd, deriveClaudeSupplementalModelProbes(discovered));
407
423
  return mergeClaudeDiscoveredModelOptions(discovered, ...supplemental);
408
424
  }
409
- async function detectSupplementalRuntimeModels(cwd) {
410
- return Promise.all(CLAUDE_SUPPLEMENTAL_MODEL_PROBES.map(async (model) => {
425
+ async function detectSupplementalRuntimeModels(cwd, aliases) {
426
+ return Promise.all(aliases.map(async (model) => {
411
427
  try {
412
428
  const options = await detectRuntimeModelsForSelection(cwd, model);
413
429
  const option = options.find((entry) => entry.value === model);
@@ -1044,6 +1060,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1044
1060
  approvalManager: config.approvalManager,
1045
1061
  canRequestApproval: config.canRequestApproval,
1046
1062
  allowedNonOwnerTools: allowedNonOwnerClaudeTools,
1063
+ ruleForcedAsk: Boolean(options.matchedAskRule),
1047
1064
  // File-change preview for Edit/Write-class approvals: canUseTool fires
1048
1065
  // before the mutation lands, so read the pre-image from disk and splice
1049
1066
  // the edit in memory. Best-effort — failure logs and omits the diff.
@@ -1209,7 +1226,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1209
1226
  resetTurnToIdle();
1210
1227
  }, FINAL_MESSAGE_HANDOFF_MS);
1211
1228
  }
1212
- function markPendingFinalDelivery(finalText, turn) {
1229
+ function markPendingFinalDelivery(finalText, turn, suppressAutoReply = false) {
1213
1230
  clearIdleResetTimer();
1214
1231
  const retryCount = session.pendingFinalDelivery?.turnKey === turn.turnKey
1215
1232
  ? session.pendingFinalDelivery.retryCount
@@ -1219,6 +1236,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1219
1236
  turnKey: turn.turnKey,
1220
1237
  text: finalText,
1221
1238
  retryCount,
1239
+ ...(suppressAutoReply ? { suppressAutoReply: true } : {}),
1222
1240
  messageId: buildClaudeFinalMessageId({
1223
1241
  agentId,
1224
1242
  conversationId,
@@ -1233,7 +1251,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1233
1251
  writeTurn();
1234
1252
  typingSignals.start(conversationId, 'typing').catch(() => { });
1235
1253
  }
1236
- async function deliverFinalReply(finalText, turn = session.activeInput) {
1254
+ async function deliverFinalReply(finalText, turn = session.activeInput, suppressAutoReply = false) {
1237
1255
  if (!shouldDeliverClaudeFinal({
1238
1256
  turn,
1239
1257
  finalizedTurnKeys: session.finalizedTurnKeys,
@@ -1255,16 +1273,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1255
1273
  ...(session.activeSelfContextId
1256
1274
  ? { selfContextId: session.activeSelfContextId }
1257
1275
  : {}),
1258
- metadata: {
1276
+ metadata: buildClaudeFinalTurnMetadata({
1259
1277
  turnId: session.currentTurnId,
1260
1278
  turnKey: deliverTurn.turnKey,
1261
- sourceMessageId: deliverTurn.sourceMessageId ?? undefined,
1262
- turnSemantics: 'turn_complete',
1263
- deliveryIntent: session.lastAcceptedIntent ?? undefined,
1264
- ...(getFinalTurnTrail().length > 0
1265
- ? { turnTrail: getFinalTurnTrail() }
1266
- : {}),
1267
- },
1279
+ sourceMessageId: deliverTurn.sourceMessageId,
1280
+ deliveryIntent: session.lastAcceptedIntent,
1281
+ turnTrail: getFinalTurnTrail(),
1282
+ suppressAutoReply,
1283
+ }),
1268
1284
  });
1269
1285
  session.finalizedTurnKeys.add(deliverTurn.turnKey);
1270
1286
  markInputCompleted(deliverTurn);
@@ -1276,7 +1292,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1276
1292
  return true;
1277
1293
  }
1278
1294
  catch (err) {
1279
- markPendingFinalDelivery(finalText, deliverTurn);
1295
+ markPendingFinalDelivery(finalText, deliverTurn, suppressAutoReply);
1280
1296
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
1281
1297
  return false;
1282
1298
  }
@@ -1336,7 +1352,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1336
1352
  const retryTurn = session.activeInput?.turnKey === pending.turnKey
1337
1353
  ? session.activeInput
1338
1354
  : null;
1339
- void deliverFinalReply(pending.text, retryTurn).then((sent) => {
1355
+ void deliverFinalReply(pending.text, retryTurn, pending.suppressAutoReply ?? false).then((sent) => {
1340
1356
  if (sent) {
1341
1357
  scheduleFinalHandoffReset();
1342
1358
  return;
@@ -1651,7 +1667,22 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1651
1667
  const resultText = typeof msg.result === 'string'
1652
1668
  ? msg.result.trim()
1653
1669
  : '';
1654
- const finalText = resultText || session.pendingFinalText?.trim() || null;
1670
+ // Error results carry no `result` text — without a notice the
1671
+ // turn would end silently and the agent just goes idle in chat.
1672
+ const failureNotice = buildClaudeTurnFailureNotice({
1673
+ subtype: msg.subtype,
1674
+ errors: msg.errors,
1675
+ });
1676
+ if (failureNotice) {
1677
+ const errorList = Array.isArray(msg.errors) ? msg.errors : [];
1678
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn failed (${msg.subtype}): `
1679
+ + (errorList.join(' | ') || 'no error detail'));
1680
+ }
1681
+ const finalText = composeClaudeFinalText({
1682
+ resultText,
1683
+ streamedText: session.pendingFinalText,
1684
+ failureNotice,
1685
+ });
1655
1686
  const shouldDeliverFinal = finalText
1656
1687
  ? shouldDeliverClaudeFinal({
1657
1688
  turn: completedInput,
@@ -1660,7 +1691,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1660
1691
  })
1661
1692
  : false;
1662
1693
  const finalDelivered = finalText && shouldDeliverFinal
1663
- ? await deliverFinalReply(finalText, completedInput)
1694
+ ? await deliverFinalReply(finalText, completedInput, Boolean(failureNotice))
1664
1695
  : true;
1665
1696
  try {
1666
1697
  const usage = await q.getContextUsage();
@@ -1757,9 +1788,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1757
1788
  // Refresh the canonical runtime descriptor after Claude reports supported models.
1758
1789
  try {
1759
1790
  const models = await q.supportedModels();
1760
- const supplemental = await detectSupplementalRuntimeModels(cwd);
1791
+ const discoveredOptions = toModelOptions(models);
1792
+ const supplemental = await detectSupplementalRuntimeModels(cwd, deriveClaudeSupplementalModelProbes(discoveredOptions));
1761
1793
  const modelList = resolveClaudeModelOptions({
1762
- discovered: mergeClaudeDiscoveredModelOptions(toModelOptions(models), ...supplemental),
1794
+ discovered: mergeClaudeDiscoveredModelOptions(discoveredOptions, ...supplemental),
1763
1795
  selectedModel: session.state.model,
1764
1796
  });
1765
1797
  session.availableModels = modelList;
@@ -2539,6 +2571,11 @@ export async function main() {
2539
2571
  content: messageContent,
2540
2572
  },
2541
2573
  parent_tool_use_id: null,
2574
+ origin: claudeOriginForCanonSender({
2575
+ senderType: m.senderType,
2576
+ senderId: m.senderId,
2577
+ senderName: m.senderName,
2578
+ }),
2542
2579
  }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
2543
2580
  return 'queued';
2544
2581
  }
@@ -2790,6 +2827,11 @@ export async function main() {
2790
2827
  content: messageContent,
2791
2828
  },
2792
2829
  parent_tool_use_id: null,
2830
+ origin: claudeOriginForCanonSender({
2831
+ senderType: m.senderType,
2832
+ senderId: m.senderId,
2833
+ senderName: m.senderName,
2834
+ }),
2793
2835
  }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
2794
2836
  return true;
2795
2837
  })().then((queued) => {
@@ -1,4 +1,4 @@
1
- import type { PermissionMode, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
1
+ import type { PermissionMode, SDKMessageOrigin, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
2
  import type { DeliveryIntent, ModelOption, TurnLifecycleState } from '@canonmsg/core';
3
3
  import type { TurnArtifactSnapshot } from '@canonmsg/coding-agent-host';
4
4
  export type ClaudeInputKind = 'seed' | 'canon';
@@ -25,6 +25,8 @@ export interface ClaudePendingFinalDelivery {
25
25
  text: string;
26
26
  messageId: string;
27
27
  retryCount: number;
28
+ /** Carried so a retried failure notice keeps suppressing agent auto-replies. */
29
+ suppressAutoReply?: boolean;
28
30
  }
29
31
  export interface ClaudeCompletedTurnState {
30
32
  state: {
@@ -67,6 +69,48 @@ export declare function shouldDeliverClaudeFinal(input: {
67
69
  interruptedTurnKeys: ReadonlySet<string>;
68
70
  }): boolean;
69
71
  export declare function resetClaudeCompletedTurnState(session: ClaudeCompletedTurnState): void;
72
+ export declare function claudeOriginForCanonSender(input: {
73
+ senderType?: string | null;
74
+ senderId: string;
75
+ senderName?: string | null;
76
+ }): SDKMessageOrigin;
77
+ /**
78
+ * Metadata for a completed-turn message. `suppressAutoReply` is set for turns
79
+ * that ended in failure: a failure notice is not a handoff, so it must not
80
+ * trigger a peer agent's auto-reply and start an error-response exchange.
81
+ */
82
+ export declare function buildClaudeFinalTurnMetadata(input: {
83
+ turnId?: string | null;
84
+ turnKey: string;
85
+ sourceMessageId?: string | null;
86
+ deliveryIntent?: DeliveryIntent | null;
87
+ turnTrail?: ReadonlyArray<unknown>;
88
+ suppressAutoReply?: boolean;
89
+ }): Record<string, unknown>;
90
+ /**
91
+ * A chat-deliverable notice for a turn that ended on an SDK error result.
92
+ * Without it the agent goes from working to idle with no message at all —
93
+ * the error subtypes carry no `result` text, and `errors` was never read.
94
+ * Returns null for success results.
95
+ */
96
+ export declare function buildClaudeTurnFailureNotice(input: {
97
+ subtype: unknown;
98
+ errors?: unknown;
99
+ }): string | null;
100
+ /**
101
+ * Final chat text for a completed turn. Success keeps today's behaviour
102
+ * (result text, else the streamed text). A failure notice is appended to any
103
+ * partial output — partial text alone would read as a successful reply.
104
+ */
105
+ export declare function composeClaudeFinalText(input: {
106
+ resultText?: string | null;
107
+ streamedText?: string | null;
108
+ failureNotice?: string | null;
109
+ }): string | null;
110
+ export declare const MINIMUM_CLAUDE_CLI_VERSION: readonly number[];
111
+ export declare function parseClaudeCliVersion(output: string): number[] | null;
112
+ export declare function formatClaudeCliVersion(version: readonly number[]): string;
113
+ export declare function isSupportedClaudeCliVersion(installed: readonly number[], minimum?: readonly number[]): boolean;
70
114
  export interface ClaudeModelInfoLike {
71
115
  value: string;
72
116
  displayName: string;
@@ -74,6 +118,14 @@ export interface ClaudeModelInfoLike {
74
118
  }
75
119
  export declare function claudeModelInfoToOption(model: ClaudeModelInfoLike): ModelOption;
76
120
  export declare function isClaudeCustomModelOption(option: ModelOption | null | undefined): boolean;
121
+ /**
122
+ * Bare model aliases worth probing beyond what default discovery lists.
123
+ * Discovery decorates values (`opus[1m]`, `claude-fable-5[1m]`) while the
124
+ * plain alias (`opus`, `fable`) is selectable but unlisted; deriving the
125
+ * aliases from the discovered set keeps the picker discovery-driven instead
126
+ * of needing a code change every time a model family ships.
127
+ */
128
+ export declare function deriveClaudeSupplementalModelProbes(discovered: ReadonlyArray<ModelOption>): string[];
77
129
  export declare function mergeClaudeDiscoveredModelOptions(...groups: ReadonlyArray<ReadonlyArray<ModelOption>>): ModelOption[];
78
130
  export declare function resolveClaudeModelOptions(input: {
79
131
  discovered: ReadonlyArray<ModelOption>;
@@ -82,7 +134,7 @@ export declare function resolveClaudeModelOptions(input: {
82
134
  export declare function formatClaudeControlError(error: unknown): string;
83
135
  export declare function confirmClaudeInterrupt(input: {
84
136
  active: boolean;
85
- interrupt: () => Promise<void>;
137
+ interrupt: () => Promise<unknown>;
86
138
  }): Promise<'confirmed' | 'defer'>;
87
139
  /** Requested session-control values from the control plane (raw, pre-validation). */
88
140
  export interface ClaudeSessionControlInput {
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
+ import { USAGE_LIMIT_ERROR_PREFIXES } from '@anthropic-ai/claude-agent-sdk';
2
3
  export function createClaudeInputEnvelope(input) {
3
4
  const sourceMessageId = input.sourceMessageId ?? null;
4
5
  const fallbackId = randomUUID();
@@ -60,6 +61,110 @@ export function resetClaudeCompletedTurnState(session) {
60
61
  session.toolInProgress = false;
61
62
  session.turnState = 'idle';
62
63
  }
64
+ // Provenance for a Canon message entering the SDK. From 0.3.220 an absent
65
+ // `origin` is "unattributed" and fails closed at strict isHuman() gates, so a
66
+ // human sender must be stamped explicitly rather than left to default. Agent
67
+ // senders keep their identity as peers instead of being laundered into humans.
68
+ export function claudeOriginForCanonSender(input) {
69
+ if (input.senderType === 'human')
70
+ return { kind: 'human' };
71
+ const name = input.senderName?.trim();
72
+ return { kind: 'peer', from: input.senderId, ...(name ? { name } : {}) };
73
+ }
74
+ /**
75
+ * Metadata for a completed-turn message. `suppressAutoReply` is set for turns
76
+ * that ended in failure: a failure notice is not a handoff, so it must not
77
+ * trigger a peer agent's auto-reply and start an error-response exchange.
78
+ */
79
+ export function buildClaudeFinalTurnMetadata(input) {
80
+ return {
81
+ turnId: input.turnId ?? null,
82
+ turnKey: input.turnKey,
83
+ sourceMessageId: input.sourceMessageId ?? undefined,
84
+ turnSemantics: 'turn_complete',
85
+ ...(input.suppressAutoReply ? { replyBehavior: 'suppress_auto_reply' } : {}),
86
+ deliveryIntent: input.deliveryIntent ?? undefined,
87
+ ...(input.turnTrail && input.turnTrail.length > 0 ? { turnTrail: input.turnTrail } : {}),
88
+ };
89
+ }
90
+ const CLAUDE_FAILURE_DETAIL_MAX_CHARS = 280;
91
+ function claudeFailureDetail(errors) {
92
+ if (!Array.isArray(errors))
93
+ return null;
94
+ const first = errors.find((entry) => typeof entry === 'string' && entry.trim());
95
+ if (typeof first !== 'string')
96
+ return null;
97
+ const trimmed = first.trim();
98
+ return trimmed.length > CLAUDE_FAILURE_DETAIL_MAX_CHARS
99
+ ? `${trimmed.slice(0, CLAUDE_FAILURE_DETAIL_MAX_CHARS - 1)}…`
100
+ : trimmed;
101
+ }
102
+ function claudeUsageLimitDetail(errors) {
103
+ if (!Array.isArray(errors))
104
+ return null;
105
+ const match = errors.find((entry) => typeof entry === 'string'
106
+ && USAGE_LIMIT_ERROR_PREFIXES.some((prefix) => entry.startsWith(prefix)));
107
+ return typeof match === 'string' ? claudeFailureDetail([match]) : null;
108
+ }
109
+ /**
110
+ * A chat-deliverable notice for a turn that ended on an SDK error result.
111
+ * Without it the agent goes from working to idle with no message at all —
112
+ * the error subtypes carry no `result` text, and `errors` was never read.
113
+ * Returns null for success results.
114
+ */
115
+ export function buildClaudeTurnFailureNotice(input) {
116
+ if (typeof input.subtype !== 'string' || !input.subtype.startsWith('error')) {
117
+ return null;
118
+ }
119
+ const usageLimit = claudeUsageLimitDetail(input.errors);
120
+ if (usageLimit) {
121
+ return `I've hit a usage limit, so this turn didn't finish: ${usageLimit}`;
122
+ }
123
+ switch (input.subtype) {
124
+ case 'error_max_turns':
125
+ return 'I stopped this turn after reaching its maximum number of turns. Send a follow-up message to continue.';
126
+ case 'error_max_budget_usd':
127
+ return 'I stopped this turn after reaching its spending budget.';
128
+ default: {
129
+ const detail = claudeFailureDetail(input.errors);
130
+ return detail
131
+ ? `This turn failed before I could reply: ${detail}`
132
+ : 'This turn failed before I could reply.';
133
+ }
134
+ }
135
+ }
136
+ /**
137
+ * Final chat text for a completed turn. Success keeps today's behaviour
138
+ * (result text, else the streamed text). A failure notice is appended to any
139
+ * partial output — partial text alone would read as a successful reply.
140
+ */
141
+ export function composeClaudeFinalText(input) {
142
+ const primary = input.resultText?.trim() || input.streamedText?.trim() || null;
143
+ if (!input.failureNotice)
144
+ return primary;
145
+ return primary ? `${primary}\n\n${input.failureNotice}` : input.failureNotice;
146
+ }
147
+ // Model options are discovery-driven: the host publishes whatever the Claude
148
+ // Code CLI it spawns reports. A CLI older than this predates whole model
149
+ // families (2.1.220 is the first to know Opus 5), so an old binary silently
150
+ // shortens the picker instead of failing. Keep this at or above the CLI the
151
+ // pinned SDK bundles, so the bundled fallback always satisfies it.
152
+ export const MINIMUM_CLAUDE_CLI_VERSION = [2, 1, 220];
153
+ export function parseClaudeCliVersion(output) {
154
+ const match = output.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
155
+ return match ? match.slice(1, 4).map(Number) : null;
156
+ }
157
+ export function formatClaudeCliVersion(version) {
158
+ return version.join('.');
159
+ }
160
+ export function isSupportedClaudeCliVersion(installed, minimum = MINIMUM_CLAUDE_CLI_VERSION) {
161
+ for (let index = 0; index < minimum.length; index += 1) {
162
+ const part = installed[index] ?? 0;
163
+ if (part !== minimum[index])
164
+ return part > minimum[index];
165
+ }
166
+ return true;
167
+ }
63
168
  function normalizeModelDescription(value) {
64
169
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
65
170
  }
@@ -87,6 +192,31 @@ export function claudeModelInfoToOption(model) {
87
192
  export function isClaudeCustomModelOption(option) {
88
193
  return option?.description?.trim().toLowerCase() === 'custom model';
89
194
  }
195
+ const CLAUDE_LONG_CONTEXT_SUFFIX = /\[1m\]$/;
196
+ const CLAUDE_FULL_MODEL_ID_FAMILY = /^claude-([a-z]+)-\d/;
197
+ /**
198
+ * Bare model aliases worth probing beyond what default discovery lists.
199
+ * Discovery decorates values (`opus[1m]`, `claude-fable-5[1m]`) while the
200
+ * plain alias (`opus`, `fable`) is selectable but unlisted; deriving the
201
+ * aliases from the discovered set keeps the picker discovery-driven instead
202
+ * of needing a code change every time a model family ships.
203
+ */
204
+ export function deriveClaudeSupplementalModelProbes(discovered) {
205
+ const values = new Set(discovered.map((option) => option.value));
206
+ const aliases = new Set();
207
+ for (const option of discovered) {
208
+ if (option.value === 'default')
209
+ continue;
210
+ const base = option.value.replace(CLAUDE_LONG_CONTEXT_SUFFIX, '');
211
+ const alias = CLAUDE_FULL_MODEL_ID_FAMILY.exec(base)?.[1] ?? base;
212
+ if (!/^[a-z]+$/.test(alias))
213
+ continue;
214
+ if (alias === option.value || values.has(alias))
215
+ continue;
216
+ aliases.add(alias);
217
+ }
218
+ return [...aliases];
219
+ }
90
220
  export function mergeClaudeDiscoveredModelOptions(...groups) {
91
221
  const byValue = new Map();
92
222
  const valueByLabel = new Map();
@@ -132,6 +262,8 @@ export function formatClaudeControlError(error) {
132
262
  }
133
263
  return 'Claude Code did not apply that control.';
134
264
  }
265
+ // `Query.interrupt()` resolves to a receipt of what survived the interrupt
266
+ // (SDK 0.3.220+). Only settlement matters here, so accept any resolved value.
135
267
  export async function confirmClaudeInterrupt(input) {
136
268
  if (!input.active)
137
269
  return 'confirmed';
@@ -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.2",
3
+ "version": "0.28.0",
4
4
  "description": "Canon channel plugin for Claude Code — messaging where AI agents are first-class citizens",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,12 +30,12 @@
30
30
  "test": "vitest run"
31
31
  },
32
32
  "dependencies": {
33
- "@anthropic-ai/claude-agent-sdk": "0.3.204",
34
- "@canonmsg/agent-sdk": "^7.0.1",
33
+ "@anthropic-ai/claude-agent-sdk": "0.3.220",
34
+ "@canonmsg/agent-sdk": "^7.1.1",
35
+ "@canonmsg/agent-tools": "^0.3.1",
35
36
  "@canonmsg/coding-agent-host": "^0.2.2",
36
- "@canonmsg/agent-tools": "^0.3.0",
37
- "@canonmsg/core": "^7.0.2",
38
- "@canonmsg/rich-cards": "^0.8.4",
37
+ "@canonmsg/core": "^8.0.0",
38
+ "@canonmsg/rich-cards": "^0.8.5",
39
39
  "@modelcontextprotocol/sdk": "^1.29.0"
40
40
  },
41
41
  "engines": {