@canonmsg/claude-code-plugin 0.21.0 → 0.21.2

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/dist/host.js CHANGED
@@ -36,8 +36,8 @@ import { ApprovalManager, readHostSessionConfig, resolveHostWorkspaceCwd, } from
36
36
  import { runCli } from './cli-entry.js';
37
37
  import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
38
38
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
39
- import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
40
- import { claudeThinkingToFlagValue, claudeThinkingToConfig, parseClaudeSessionDefaults, parseClaudeThinkingMode, parseClaudeUltracodeMode, resolveEffectiveSessionSettings, } from './session-defaults.js';
39
+ import { applyClaudeSessionControl, buildClaudeFinalMessageId, buildClaudeInitialSessionState, claudeModelInfoToOption, createClaudeInputEnvelope, formatClaudeControlError, isClaudeCustomModelOption, normalizeClaudeEffortLevel, parseClaudePermissionMode, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
40
+ import { claudeThinkingToConfig, parseClaudeSessionDefaults, resolveEffectiveSessionSettings, } from './session-defaults.js';
41
41
  import { mapClaudeSubagentActivity } from './subagent-activity.js';
42
42
  function parseRuntimeVisibilityPreset(value) {
43
43
  return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
@@ -295,26 +295,6 @@ function buildClaudeRuntimeDescriptor(input) {
295
295
  commands,
296
296
  });
297
297
  }
298
- const CLAUDE_EFFORT_VALUES = new Set(CLAUDE_EFFORT_OPTIONS.map((option) => option.value));
299
- function parseClaudePermissionMode(value) {
300
- if (value === 'acceptEdits'
301
- || value === 'auto'
302
- || value === 'bypassPermissions'
303
- || value === 'default'
304
- || value === 'dontAsk'
305
- || value === 'plan') {
306
- return value;
307
- }
308
- return null;
309
- }
310
- function normalizeClaudeEffortLevel(value) {
311
- return typeof value === 'string' && CLAUDE_EFFORT_VALUES.has(value)
312
- ? value
313
- : undefined;
314
- }
315
- async function applyClaudeEffortLevel(q, effort) {
316
- await q.applyFlagSettings({ effortLevel: effort });
317
- }
318
298
  let cachedClaudeCliPath;
319
299
  // The SDK bundles a Bun-compiled `claude` Mach-O binary next to
320
300
  // claude-agent-sdk-darwin-arm64. On some macOS setups that exact binary gets
@@ -351,11 +331,20 @@ function resolveClaudeCliPath() {
351
331
  console.error('[canon-host] claude CLI not on PATH; using SDK-bundled binary');
352
332
  return undefined;
353
333
  }
334
+ const CLAUDE_SUPPLEMENTAL_MODEL_PROBES = ['opus'];
335
+ function mergeModelOptions(...groups) {
336
+ const byValue = new Map();
337
+ for (const group of groups) {
338
+ for (const option of group) {
339
+ if (!byValue.has(option.value)) {
340
+ byValue.set(option.value, option);
341
+ }
342
+ }
343
+ }
344
+ return [...byValue.values()];
345
+ }
354
346
  function toModelOptions(models) {
355
- return models.map((model) => ({
356
- value: model.value,
357
- label: model.displayName,
358
- }));
347
+ return models.map(claudeModelInfoToOption);
359
348
  }
360
349
  async function publishAgentRuntime(port, runtime) {
361
350
  await port.publishHostAgentRuntime({
@@ -395,6 +384,21 @@ function resolveExecutionFallbackReason(environment) {
395
384
  : environment.reason;
396
385
  }
397
386
  async function detectRuntimeModels(cwd) {
387
+ const discovered = await detectRuntimeModelsForSelection(cwd, 'default');
388
+ const supplemental = await Promise.all(CLAUDE_SUPPLEMENTAL_MODEL_PROBES.map(async (model) => {
389
+ try {
390
+ const options = await detectRuntimeModelsForSelection(cwd, model);
391
+ const option = options.find((entry) => entry.value === model);
392
+ return option && !isClaudeCustomModelOption(option) ? [option] : [];
393
+ }
394
+ catch (error) {
395
+ console.error(`[canon-host] Supplemental Claude model probe failed for ${model}:`, error);
396
+ return [];
397
+ }
398
+ }));
399
+ return mergeModelOptions(discovered, ...supplemental);
400
+ }
401
+ async function detectRuntimeModelsForSelection(cwd, model) {
398
402
  const emptyPrompt = {
399
403
  async *[Symbol.asyncIterator]() { },
400
404
  };
@@ -404,6 +408,7 @@ async function detectRuntimeModels(cwd) {
404
408
  options: {
405
409
  systemPrompt: { type: 'preset', preset: 'claude_code' },
406
410
  tools: { type: 'preset', preset: 'claude_code' },
411
+ model,
407
412
  permissionMode: 'default',
408
413
  cwd,
409
414
  settings: { forceLoginMethod: 'claudeai' },
@@ -431,6 +436,7 @@ async function inspectClaudeRuntimeMetadata(cwd, activeQuery) {
431
436
  options: {
432
437
  systemPrompt: { type: 'preset', preset: 'claude_code' },
433
438
  tools: { type: 'preset', preset: 'claude_code' },
439
+ model: 'default',
434
440
  permissionMode: 'default',
435
441
  cwd,
436
442
  settings: { forceLoginMethod: 'claudeai' },
@@ -871,6 +877,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
871
877
  const queryOptions = {
872
878
  systemPrompt: { type: 'preset', preset: 'claude_code' },
873
879
  tools: { type: 'preset', preset: 'claude_code' },
880
+ model: config.model ?? 'default',
874
881
  permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
875
882
  allowDangerouslySkipPermissions: true,
876
883
  cwd,
@@ -1092,16 +1099,16 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1092
1099
  cwd,
1093
1100
  environment,
1094
1101
  query: q,
1102
+ controlApplySeq: new Map(),
1095
1103
  sendInput,
1096
1104
  enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactRoutingMode),
1097
- state: {
1098
- model: undefined,
1105
+ state: buildClaudeInitialSessionState({
1106
+ model: config.model,
1099
1107
  permissionMode: config.permissionMode,
1100
- effort: undefined,
1108
+ effort: config.effort,
1101
1109
  fallbackModel: config.sessionSettings.fallbackModel,
1102
1110
  runtimeControlValues: config.sessionSettings.runtimeControlValues,
1103
- state: 'idle',
1104
- },
1111
+ }),
1105
1112
  availableModels: config.availableModels,
1106
1113
  streamingText: '',
1107
1114
  streamingTimer: null,
@@ -1140,12 +1147,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1140
1147
  capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1141
1148
  fallbackTurnId: `claude-${conversationId}`,
1142
1149
  streamingThrottleMs: STREAMING_THROTTLE_MS,
1143
- requestedControls: {
1144
- model: config.model,
1145
- permissionMode: config.permissionMode,
1146
- effort: config.effort,
1147
- runtimeControlValues: config.sessionSettings.runtimeControlValues,
1148
- },
1150
+ requestedControls: {},
1149
1151
  }, {
1150
1152
  session: () => session,
1151
1153
  port,
@@ -1161,6 +1163,9 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1161
1163
  formatControlError: formatClaudeControlError,
1162
1164
  });
1163
1165
  const { streamingOutput, flushStreaming, onStreamDelta, clearStreaming, getFinalTurnTrail, writeState, writeTurn, setRuntimeControlError, clearRuntimeControlError, refreshVisibleWorkSignal, startVisibleWorkSignal, stopVisibleWorkSignal, } = writers;
1166
+ // Initial values come from persisted session config and are effective for
1167
+ // this query. Publish immediately so controls do not linger as requested.
1168
+ writeState();
1164
1169
  function isActiveTurnInterrupted() {
1165
1170
  const turnKey = session.activeInput?.turnKey;
1166
1171
  return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
@@ -1485,24 +1490,6 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1485
1490
  const init = async () => {
1486
1491
  processMessages().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] processMessages crashed:`, err));
1487
1492
  await q.initializationResult();
1488
- if (config.model) {
1489
- try {
1490
- await q.setModel(config.model);
1491
- session.state.model = config.model;
1492
- }
1493
- catch (err) {
1494
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial model:`, err);
1495
- }
1496
- }
1497
- if (config.effort) {
1498
- try {
1499
- await applyClaudeEffortLevel(q, config.effort);
1500
- session.state.effort = config.effort;
1501
- }
1502
- catch (err) {
1503
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial effort:`, err);
1504
- }
1505
- }
1506
1493
  if (!resumeSessionId) {
1507
1494
  // New session — send seed message to activate streaming input mode
1508
1495
  sendInput(createClaudeInputEnvelope({
@@ -1517,16 +1504,22 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1517
1504
  },
1518
1505
  }));
1519
1506
  }
1520
- // Refresh the canonical runtime descriptor after Claude reports supported models.
1507
+ // Refresh the canonical runtime descriptor from a neutral probe. The live
1508
+ // session may be initialized with a selected model, which can narrow the
1509
+ // SDK's cached model list.
1521
1510
  try {
1522
- const models = await q.supportedModels();
1523
- const modelList = mergeClaudeModelOptions({
1524
- previous: runtimeModels,
1525
- next: toModelOptions(models),
1511
+ const discoveredModels = await detectRuntimeModels(cwd).catch((error) => {
1512
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Model discovery failed:`, error);
1513
+ return runtimeModels;
1514
+ });
1515
+ if (discoveredModels.length > 0) {
1516
+ runtimeModels = discoveredModels;
1517
+ }
1518
+ const modelList = resolveClaudeModelOptions({
1519
+ discovered: runtimeModels,
1526
1520
  selectedModel: session.state.model,
1527
1521
  });
1528
1522
  session.availableModels = modelList;
1529
- runtimeModels = modelList;
1530
1523
  onRuntimeDescriptorUpdate({
1531
1524
  defaultWorkspaceId: workspaceOptions[0]?.id,
1532
1525
  defaultModel: config.sessionSettings.model ?? modelList[0]?.value,
@@ -1544,7 +1537,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1544
1537
  }),
1545
1538
  });
1546
1539
  writeState();
1547
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${models.map((m) => m.displayName).join(', ')}`);
1540
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${modelList.map((m) => m.label).join(', ')}`);
1548
1541
  }
1549
1542
  catch {
1550
1543
  writeState();
@@ -1702,31 +1695,12 @@ export async function main() {
1702
1695
  refreshFloorMs: HEARTBEAT_MS,
1703
1696
  }, { port });
1704
1697
  const { conversationCache, knownConversationIds, getConversationMeta, refreshKnownConversationIds, handleConversationUpdated, markGroupContextModeUsed, loadHydratedInboundContext, } = directory;
1705
- const publishRuntimeHeartbeat = async () => {
1706
- heartbeatLocalRuntimeEntry(runtimeId, {
1707
- agentId,
1708
- agentName: profileAgentName,
1709
- cwd: workingDir,
1710
- baseCwd: workingDir,
1711
- });
1712
- if (!streamConnected)
1698
+ const publishSessionSnapshotsForConversations = async (conversationIds) => {
1699
+ const ids = Array.from(new Set(conversationIds));
1700
+ if (!streamConnected || ids.length === 0)
1713
1701
  return;
1714
- await refreshKnownConversationIds().catch((error) => {
1715
- console.error('[canon-host] Failed to refresh known conversations:', error);
1716
- });
1717
- const activeSessionQuery = sessions.values().next().value?.query ?? null;
1718
- const metadata = await refreshClaudeRuntimeMetadata({
1719
- cwd: workingDir,
1720
- activeQuery: activeSessionQuery,
1721
- }).catch((error) => {
1722
- console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
1723
- return runtimeMetadata;
1724
- });
1725
- await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
1726
- console.error('[canon-host] Failed to publish agent runtime:', error);
1727
- });
1728
1702
  await port.publishHostSessionSnapshots({
1729
- conversationIds: Array.from(knownConversationIds),
1703
+ conversationIds: ids,
1730
1704
  clientType: 'claude-code',
1731
1705
  runtime: runtimeDescriptor,
1732
1706
  workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
@@ -1752,6 +1726,31 @@ export async function main() {
1752
1726
  }).catch((error) => {
1753
1727
  console.error('[canon-host] Failed to publish session snapshots:', error);
1754
1728
  });
1729
+ };
1730
+ const publishRuntimeHeartbeat = async () => {
1731
+ heartbeatLocalRuntimeEntry(runtimeId, {
1732
+ agentId,
1733
+ agentName: profileAgentName,
1734
+ cwd: workingDir,
1735
+ baseCwd: workingDir,
1736
+ });
1737
+ if (!streamConnected)
1738
+ return;
1739
+ await refreshKnownConversationIds().catch((error) => {
1740
+ console.error('[canon-host] Failed to refresh known conversations:', error);
1741
+ });
1742
+ const activeSessionQuery = sessions.values().next().value?.query ?? null;
1743
+ const metadata = await refreshClaudeRuntimeMetadata({
1744
+ cwd: workingDir,
1745
+ activeQuery: activeSessionQuery,
1746
+ }).catch((error) => {
1747
+ console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
1748
+ return runtimeMetadata;
1749
+ });
1750
+ await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
1751
+ console.error('[canon-host] Failed to publish agent runtime:', error);
1752
+ });
1753
+ await publishSessionSnapshotsForConversations(knownConversationIds);
1755
1754
  // Rebuild so the descriptor picks up native slash commands from the
1756
1755
  // metadata refresh above — the initial descriptor is built before the
1757
1756
  // command inventory has loaded.
@@ -2373,105 +2372,19 @@ export async function main() {
2373
2372
  },
2374
2373
  });
2375
2374
  // ── Control signal watcher ──
2376
- // Session control signals (model, mode, effort). A missing/closed session
2377
- // leaves the node in place; lastSeen still advances, so a stale control is
2378
- // never replayed once a session exists.
2375
+ // Session control signals (model, mode, effort). The bridge has already
2376
+ // consumed the control notification, so every path must publish an answer.
2379
2377
  async function applyRuntimeSessionControl(event) {
2380
2378
  const convoId = event.conversationId;
2381
2379
  const session = sessions.get(convoId);
2382
- if (!session || session.closed)
2383
- return { consume: false };
2380
+ if (!session || session.closed) {
2381
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Control received without live session; publishing persisted snapshot`);
2382
+ void publishSessionSnapshotsForConversations([convoId]);
2383
+ return;
2384
+ }
2384
2385
  const ctrl = event.control;
2385
2386
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
2386
- if (ctrl.model && ctrl.model !== session.state.model) {
2387
- try {
2388
- await session.query.setModel(ctrl.model);
2389
- session.state.model = ctrl.model;
2390
- session.clearRuntimeControlError('model');
2391
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Model → ${ctrl.model}`);
2392
- }
2393
- catch (err) {
2394
- session.setRuntimeControlError('model', ctrl.model, err);
2395
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
2396
- }
2397
- }
2398
- const nextPermissionMode = parseClaudePermissionMode(ctrl.permissionMode);
2399
- if (ctrl.permissionMode && !nextPermissionMode) {
2400
- session.setRuntimeControlError('permissionMode', ctrl.permissionMode, new Error(`Unsupported Claude permission mode: ${ctrl.permissionMode}`));
2401
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Unsupported mode: ${ctrl.permissionMode}`);
2402
- }
2403
- else if (nextPermissionMode && nextPermissionMode !== session.state.permissionMode) {
2404
- try {
2405
- await session.query.setPermissionMode(nextPermissionMode);
2406
- session.state.permissionMode = nextPermissionMode;
2407
- session.clearRuntimeControlError('permissionMode');
2408
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${nextPermissionMode}`);
2409
- }
2410
- catch (err) {
2411
- session.setRuntimeControlError('permissionMode', nextPermissionMode, err);
2412
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
2413
- }
2414
- }
2415
- const nextEffort = normalizeClaudeEffortLevel(ctrl.effort);
2416
- if (nextEffort && nextEffort !== session.state.effort) {
2417
- try {
2418
- await applyClaudeEffortLevel(session.query, nextEffort);
2419
- session.state.effort = nextEffort;
2420
- session.clearRuntimeControlError('effort');
2421
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Effort → ${nextEffort}`);
2422
- }
2423
- catch (err) {
2424
- session.setRuntimeControlError('effort', nextEffort, err);
2425
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err);
2426
- }
2427
- }
2428
- else if (ctrl.effort && !nextEffort) {
2429
- session.setRuntimeControlError('effort', String(ctrl.effort), new Error('Unsupported Claude thinking level.'));
2430
- }
2431
- const runtimeControlValues = ctrl.runtimeControlValues ?? {};
2432
- if ('thinking' in runtimeControlValues) {
2433
- const nextThinking = parseClaudeThinkingMode(runtimeControlValues.thinking);
2434
- if (!nextThinking) {
2435
- session.setRuntimeControlError('thinking', String(runtimeControlValues.thinking), new Error('Unsupported Claude thinking mode.'));
2436
- }
2437
- else if (nextThinking !== session.state.runtimeControlValues?.thinking) {
2438
- try {
2439
- await session.query.applyFlagSettings({ alwaysThinkingEnabled: claudeThinkingToFlagValue(nextThinking) });
2440
- session.state.runtimeControlValues = {
2441
- ...(session.state.runtimeControlValues ?? {}),
2442
- thinking: nextThinking,
2443
- };
2444
- session.clearRuntimeControlError('thinking');
2445
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Thinking → ${nextThinking}`);
2446
- }
2447
- catch (err) {
2448
- session.setRuntimeControlError('thinking', nextThinking, err);
2449
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set thinking:`, err);
2450
- }
2451
- }
2452
- }
2453
- if ('ultracode' in runtimeControlValues) {
2454
- const nextUltracode = parseClaudeUltracodeMode(runtimeControlValues.ultracode);
2455
- if (!nextUltracode) {
2456
- session.setRuntimeControlError('ultracode', String(runtimeControlValues.ultracode), new Error('Unsupported Claude ultracode mode.'));
2457
- }
2458
- else if (nextUltracode !== session.state.runtimeControlValues?.ultracode) {
2459
- try {
2460
- await session.query.applyFlagSettings({ ultracode: nextUltracode === 'on' });
2461
- session.state.runtimeControlValues = {
2462
- ...(session.state.runtimeControlValues ?? {}),
2463
- ultracode: nextUltracode,
2464
- };
2465
- session.clearRuntimeControlError('ultracode');
2466
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ultracode → ${nextUltracode}`);
2467
- }
2468
- catch (err) {
2469
- session.setRuntimeControlError('ultracode', nextUltracode, err);
2470
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set ultracode:`, err);
2471
- }
2472
- }
2473
- }
2474
- session.writeState();
2387
+ applyClaudeSessionControl(session, ctrl);
2475
2388
  }
2476
2389
  // ── Heartbeat + idle cleanup + graceful shutdown (agent-host lifecycle) ──
2477
2390
  lifecycle.startTimers();
@@ -18,6 +18,7 @@ export interface ClaudeSessionControlValues {
18
18
  export interface ClaudeEffectiveSessionSettings extends ClaudeSessionDefaults {
19
19
  runtimeControlValues: Record<string, string>;
20
20
  }
21
+ export declare const CLAUDE_EFFORT_LEVELS: readonly ClaudeEffortLevel[];
21
22
  export declare function parseClaudeThinkingMode(value: unknown): ClaudeThinkingMode | undefined;
22
23
  export declare function parseClaudeUltracodeMode(value: unknown): ClaudeUltracodeMode | undefined;
23
24
  export declare function parseClaudeSessionDefaults(raw: unknown): ClaudeSessionDefaults;
@@ -1,4 +1,5 @@
1
- const CLAUDE_EFFORT_VALUES = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
1
+ export const CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
2
+ const CLAUDE_EFFORT_VALUES = new Set(CLAUDE_EFFORT_LEVELS);
2
3
  function optionalString(value) {
3
4
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
4
5
  }
@@ -1,6 +1,8 @@
1
- import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
1
+ import type { Query, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
2
  import type { DeliveryIntent, ModelOption, TurnLifecycleState } from '@canonmsg/core/contract';
3
3
  import type { TurnArtifactSnapshot } from '@canonmsg/agent-host';
4
+ import { type ClaudeEffortLevel } from './session-defaults.js';
5
+ export type { ClaudeEffortLevel } from './session-defaults.js';
4
6
  export type ClaudeInputKind = 'seed' | 'canon';
5
7
  export type ClaudeArtifactRoutingMode = 'workspace-generated' | 'disabled';
6
8
  export interface ClaudeInputEnvelope {
@@ -19,6 +21,47 @@ export interface ClaudeRuntimeControlError {
19
21
  message: string;
20
22
  updatedAt: number;
21
23
  }
24
+ export type ClaudePermissionMode = Parameters<Query['setPermissionMode']>[0];
25
+ export interface ClaudeSessionControl {
26
+ model?: string;
27
+ permissionMode?: string;
28
+ effort?: string;
29
+ runtimeControlValues?: Record<string, string>;
30
+ updatedAt?: number;
31
+ updatedBy?: string;
32
+ }
33
+ export interface ClaudeHostSessionState {
34
+ model?: string;
35
+ permissionMode?: string;
36
+ effort?: string;
37
+ fallbackModel?: string;
38
+ runtimeControlValues?: Record<string, string>;
39
+ state?: 'idle' | 'running' | 'requires_action';
40
+ contextUsage?: {
41
+ percentage: number;
42
+ totalTokens: number;
43
+ maxTokens: number;
44
+ };
45
+ }
46
+ export declare function parseClaudePermissionMode(value: unknown): ClaudePermissionMode | null;
47
+ export declare function normalizeClaudeEffortLevel(value: unknown): ClaudeEffortLevel | undefined;
48
+ export declare function buildClaudeInitialSessionState(input: {
49
+ model?: string;
50
+ permissionMode?: string;
51
+ effort?: string;
52
+ fallbackModel?: string;
53
+ runtimeControlValues?: Record<string, string>;
54
+ }): ClaudeHostSessionState;
55
+ export interface ClaudeSessionControlTarget {
56
+ state: ClaudeHostSessionState;
57
+ controlApplySeq: Map<string, number>;
58
+ closed: boolean;
59
+ query: Pick<Query, 'setModel' | 'setPermissionMode' | 'applyFlagSettings'>;
60
+ writeState: () => void;
61
+ setRuntimeControlError: (controlId: string, value: string, error: unknown) => void;
62
+ clearRuntimeControlError: (controlId: string) => void;
63
+ }
64
+ export declare function applyClaudeSessionControl(target: ClaudeSessionControlTarget, ctrl: ClaudeSessionControl): void;
22
65
  export interface ClaudePendingFinalDelivery {
23
66
  turnKey: string;
24
67
  text: string;
@@ -60,9 +103,15 @@ export declare function shouldDeliverClaudeFinal(input: {
60
103
  interruptedTurnKeys: ReadonlySet<string>;
61
104
  }): boolean;
62
105
  export declare function resetClaudeCompletedTurnState(session: ClaudeCompletedTurnState): void;
63
- export declare function mergeClaudeModelOptions(input: {
64
- previous: ReadonlyArray<ModelOption>;
65
- next: ReadonlyArray<ModelOption>;
106
+ export interface ClaudeModelInfoLike {
107
+ value: string;
108
+ displayName: string;
109
+ description?: string;
110
+ }
111
+ export declare function claudeModelInfoToOption(model: ClaudeModelInfoLike): ModelOption;
112
+ export declare function isClaudeCustomModelOption(option: ModelOption | null | undefined): boolean;
113
+ export declare function resolveClaudeModelOptions(input: {
114
+ discovered: ReadonlyArray<ModelOption>;
66
115
  selectedModel?: string | null;
67
116
  }): ModelOption[];
68
117
  export declare function formatClaudeControlError(error: unknown): string;
@@ -1,4 +1,173 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
+ import { CLAUDE_EFFORT_LEVELS, claudeThinkingToFlagValue, parseClaudeThinkingMode, parseClaudeUltracodeMode, } from './session-defaults.js';
3
+ const CLAUDE_PERMISSION_MODE_VALUES = new Set([
4
+ 'acceptEdits',
5
+ 'auto',
6
+ 'bypassPermissions',
7
+ 'default',
8
+ 'dontAsk',
9
+ 'plan',
10
+ ]);
11
+ const CLAUDE_EFFORT_VALUES = new Set(CLAUDE_EFFORT_LEVELS);
12
+ export function parseClaudePermissionMode(value) {
13
+ return typeof value === 'string' && CLAUDE_PERMISSION_MODE_VALUES.has(value)
14
+ ? value
15
+ : null;
16
+ }
17
+ export function normalizeClaudeEffortLevel(value) {
18
+ return typeof value === 'string' && CLAUDE_EFFORT_VALUES.has(value)
19
+ ? value
20
+ : undefined;
21
+ }
22
+ export function buildClaudeInitialSessionState(input) {
23
+ return {
24
+ ...(input.model ? { model: input.model } : {}),
25
+ ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}),
26
+ ...(input.effort ? { effort: input.effort } : {}),
27
+ ...(input.fallbackModel ? { fallbackModel: input.fallbackModel } : {}),
28
+ ...(input.runtimeControlValues ? { runtimeControlValues: input.runtimeControlValues } : {}),
29
+ state: 'idle',
30
+ };
31
+ }
32
+ function nextControlApplySeq(target, controlId) {
33
+ const nextSeq = (target.controlApplySeq.get(controlId) ?? 0) + 1;
34
+ target.controlApplySeq.set(controlId, nextSeq);
35
+ return nextSeq;
36
+ }
37
+ function applyOptimisticControl(input) {
38
+ const seq = nextControlApplySeq(input.target, input.controlId);
39
+ const applyPromise = (async () => input.apply())();
40
+ void applyPromise
41
+ .catch((error) => {
42
+ if (input.target.closed || input.target.controlApplySeq.get(input.controlId) !== seq) {
43
+ return;
44
+ }
45
+ input.revert();
46
+ input.target.setRuntimeControlError(input.controlId, input.value, error);
47
+ });
48
+ }
49
+ function setRuntimeControlValue(state, controlId, value) {
50
+ state.runtimeControlValues = {
51
+ ...(state.runtimeControlValues ?? {}),
52
+ [controlId]: value,
53
+ };
54
+ }
55
+ export function applyClaudeSessionControl(target, ctrl) {
56
+ const nextModel = typeof ctrl.model === 'string' && ctrl.model.trim() ? ctrl.model.trim() : null;
57
+ if (ctrl.model !== undefined && !nextModel) {
58
+ target.setRuntimeControlError('model', String(ctrl.model), new Error('Unsupported Claude model.'));
59
+ }
60
+ else if (nextModel && nextModel !== target.state.model) {
61
+ const previous = target.state.model;
62
+ target.state.model = nextModel;
63
+ target.clearRuntimeControlError('model');
64
+ applyOptimisticControl({
65
+ target,
66
+ controlId: 'model',
67
+ value: nextModel,
68
+ apply: () => target.query.setModel(nextModel),
69
+ revert: () => {
70
+ if (previous === undefined)
71
+ delete target.state.model;
72
+ else
73
+ target.state.model = previous;
74
+ },
75
+ });
76
+ }
77
+ const nextPermissionMode = parseClaudePermissionMode(ctrl.permissionMode);
78
+ if (ctrl.permissionMode !== undefined && !nextPermissionMode) {
79
+ target.setRuntimeControlError('permissionMode', ctrl.permissionMode, new Error(`Unsupported Claude permission mode: ${ctrl.permissionMode}`));
80
+ }
81
+ else if (nextPermissionMode && nextPermissionMode !== target.state.permissionMode) {
82
+ // Claude Code applies permission-mode changes asynchronously. We publish
83
+ // the requested value immediately so Canon does not show a stale "requested"
84
+ // state while the SDK catches up, then revert if the current apply fails.
85
+ const previous = target.state.permissionMode;
86
+ target.state.permissionMode = nextPermissionMode;
87
+ target.clearRuntimeControlError('permissionMode');
88
+ applyOptimisticControl({
89
+ target,
90
+ controlId: 'permissionMode',
91
+ value: nextPermissionMode,
92
+ apply: () => target.query.setPermissionMode(nextPermissionMode),
93
+ revert: () => {
94
+ if (previous === undefined)
95
+ delete target.state.permissionMode;
96
+ else
97
+ target.state.permissionMode = previous;
98
+ },
99
+ });
100
+ }
101
+ const nextEffort = normalizeClaudeEffortLevel(ctrl.effort);
102
+ if (nextEffort && nextEffort !== target.state.effort) {
103
+ const previous = target.state.effort;
104
+ target.state.effort = nextEffort;
105
+ target.clearRuntimeControlError('effort');
106
+ applyOptimisticControl({
107
+ target,
108
+ controlId: 'effort',
109
+ value: nextEffort,
110
+ apply: () => target.query.applyFlagSettings({ effortLevel: nextEffort }),
111
+ revert: () => {
112
+ if (previous === undefined)
113
+ delete target.state.effort;
114
+ else
115
+ target.state.effort = previous;
116
+ },
117
+ });
118
+ }
119
+ else if (ctrl.effort !== undefined && !nextEffort) {
120
+ target.setRuntimeControlError('effort', String(ctrl.effort), new Error('Unsupported Claude thinking level.'));
121
+ }
122
+ const runtimeControlValues = ctrl.runtimeControlValues ?? {};
123
+ if ('thinking' in runtimeControlValues) {
124
+ const nextThinking = parseClaudeThinkingMode(runtimeControlValues.thinking);
125
+ if (!nextThinking) {
126
+ target.setRuntimeControlError('thinking', String(runtimeControlValues.thinking), new Error('Unsupported Claude thinking mode.'));
127
+ }
128
+ else if (nextThinking !== target.state.runtimeControlValues?.thinking) {
129
+ const previous = target.state.runtimeControlValues?.thinking;
130
+ setRuntimeControlValue(target.state, 'thinking', nextThinking);
131
+ target.clearRuntimeControlError('thinking');
132
+ applyOptimisticControl({
133
+ target,
134
+ controlId: 'thinking',
135
+ value: nextThinking,
136
+ apply: () => target.query.applyFlagSettings({ alwaysThinkingEnabled: claudeThinkingToFlagValue(nextThinking) }),
137
+ revert: () => {
138
+ if (previous === undefined)
139
+ delete target.state.runtimeControlValues?.thinking;
140
+ else
141
+ setRuntimeControlValue(target.state, 'thinking', previous);
142
+ },
143
+ });
144
+ }
145
+ }
146
+ if ('ultracode' in runtimeControlValues) {
147
+ const nextUltracode = parseClaudeUltracodeMode(runtimeControlValues.ultracode);
148
+ if (!nextUltracode) {
149
+ target.setRuntimeControlError('ultracode', String(runtimeControlValues.ultracode), new Error('Unsupported Claude ultracode mode.'));
150
+ }
151
+ else if (nextUltracode !== target.state.runtimeControlValues?.ultracode) {
152
+ const previous = target.state.runtimeControlValues?.ultracode;
153
+ setRuntimeControlValue(target.state, 'ultracode', nextUltracode);
154
+ target.clearRuntimeControlError('ultracode');
155
+ applyOptimisticControl({
156
+ target,
157
+ controlId: 'ultracode',
158
+ value: nextUltracode,
159
+ apply: () => target.query.applyFlagSettings({ ultracode: nextUltracode === 'on' }),
160
+ revert: () => {
161
+ if (previous === undefined)
162
+ delete target.state.runtimeControlValues?.ultracode;
163
+ else
164
+ setRuntimeControlValue(target.state, 'ultracode', previous);
165
+ },
166
+ });
167
+ }
168
+ }
169
+ target.writeState();
170
+ }
2
171
  export function createClaudeInputEnvelope(input) {
3
172
  const sourceMessageId = input.sourceMessageId ?? null;
4
173
  const fallbackId = randomUUID();
@@ -46,32 +215,45 @@ export function resetClaudeCompletedTurnState(session) {
46
215
  session.toolInProgress = false;
47
216
  session.turnState = 'idle';
48
217
  }
49
- export function mergeClaudeModelOptions(input) {
218
+ function normalizeModelDescription(value) {
219
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
220
+ }
221
+ function displayLabelWithClaudeVersion(input) {
222
+ const label = input.displayName.trim() || input.value;
223
+ const description = normalizeModelDescription(input.description);
224
+ if (!description || label.includes('(') || /\d/.test(label))
225
+ return label;
226
+ const [summary] = description.split('·');
227
+ const versionedLabel = summary?.trim();
228
+ return versionedLabel
229
+ && versionedLabel.toLowerCase().startsWith(label.toLowerCase())
230
+ && /\d/.test(versionedLabel)
231
+ ? versionedLabel
232
+ : label;
233
+ }
234
+ export function claudeModelInfoToOption(model) {
235
+ return {
236
+ value: model.value,
237
+ label: displayLabelWithClaudeVersion(model),
238
+ ...(normalizeModelDescription(model.description) ? { description: normalizeModelDescription(model.description) } : {}),
239
+ };
240
+ }
241
+ export function isClaudeCustomModelOption(option) {
242
+ return option?.description?.trim().toLowerCase() === 'custom model';
243
+ }
244
+ export function resolveClaudeModelOptions(input) {
50
245
  const byValue = new Map();
51
- for (const option of input.previous) {
52
- byValue.set(option.value, { ...option });
53
- }
54
- for (const option of input.next) {
246
+ for (const option of input.discovered) {
55
247
  byValue.set(option.value, { ...option });
56
248
  }
57
249
  if (input.selectedModel && !byValue.has(input.selectedModel)) {
58
250
  byValue.set(input.selectedModel, {
59
251
  value: input.selectedModel,
60
252
  label: input.selectedModel,
61
- description: 'Selected for this Canon session but not returned by the latest Claude model probe.',
253
+ description: 'Selected for this Canon session but not returned by Claude model discovery.',
62
254
  });
63
255
  }
64
- const nextValues = new Set(input.next.map((option) => option.value));
65
- return [...byValue.values()].map((option) => {
66
- if (nextValues.has(option.value))
67
- return option;
68
- return {
69
- ...option,
70
- description: option.description
71
- ? `${option.description} Previously advertised; not returned by the latest Claude model probe.`
72
- : 'Previously advertised; not returned by the latest Claude model probe.',
73
- };
74
- });
256
+ return [...byValue.values()];
75
257
  }
76
258
  export function formatClaudeControlError(error) {
77
259
  if (error instanceof Error && error.message.trim()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
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",
@@ -33,8 +33,8 @@
33
33
  "@anthropic-ai/claude-agent-sdk": "^0.3.200",
34
34
  "@canonmsg/agent-host": "^0.4.0",
35
35
  "@canonmsg/agent-sdk": "^3.4.4",
36
- "@canonmsg/bridge": "^0.2.0",
37
- "@canonmsg/core": "^3.2.0",
36
+ "@canonmsg/bridge": "^0.2.3",
37
+ "@canonmsg/core": "^3.2.1",
38
38
  "@canonmsg/framework": "^0.2.0",
39
39
  "@modelcontextprotocol/sdk": "^1.29.0"
40
40
  },