@canonmsg/claude-code-plugin 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/host.js CHANGED
@@ -36,7 +36,9 @@ 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';
39
+ import { applyClaudeSessionControl, buildClaudeFinalMessageId, buildClaudeInitialSessionState, createClaudeInputEnvelope, formatClaudeControlError, normalizeClaudeEffortLevel, parseClaudePermissionMode, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
40
+ import { claudeThinkingToConfig, parseClaudeSessionDefaults, resolveEffectiveSessionSettings, } from './session-defaults.js';
41
+ import { mapClaudeSubagentActivity } from './subagent-activity.js';
40
42
  function parseRuntimeVisibilityPreset(value) {
41
43
  return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
42
44
  }
@@ -87,6 +89,29 @@ let workspaceRootMetadata = [];
87
89
  let runtimeModels = [];
88
90
  const CLAUDE_METADATA_TTL_MS = 5 * 60 * 1000;
89
91
  const allowedNonOwnerClaudeTools = parseAllowedNonOwnerClaudeTools(process.env.CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS);
92
+ const CLAUDE_THINKING_CONTROL_OPTIONS = [
93
+ {
94
+ value: 'adaptive',
95
+ label: 'Adaptive',
96
+ description: 'Let Claude decide when and how much to think.',
97
+ },
98
+ {
99
+ value: 'off',
100
+ label: 'Off',
101
+ description: 'Disable extended thinking for this session.',
102
+ },
103
+ ];
104
+ const CLAUDE_ULTRACODE_CONTROL_OPTIONS = [
105
+ {
106
+ value: 'off',
107
+ label: 'Off',
108
+ },
109
+ {
110
+ value: 'on',
111
+ label: 'On',
112
+ description: 'Enable Claude Code ultracode mode when the selected model supports it.',
113
+ },
114
+ ];
90
115
  let runtimeMetadata = {
91
116
  account: null,
92
117
  mcpServers: [],
@@ -211,15 +236,40 @@ function buildClaudeRuntimeDescriptor(input) {
211
236
  RUNTIME_STOP_AND_DROP_ACTION,
212
237
  ];
213
238
  commands.push(...buildNativeSlashCommandDescriptors(runtimeMetadata.commands, new Set(commands.flatMap((command) => [command.id, ...(command.aliases ?? [])]))));
239
+ const extraRuntimeControls = [
240
+ {
241
+ id: 'thinking',
242
+ label: 'Thinking',
243
+ description: 'Claude SDK thinking mode. Mid-session changes toggle thinking on or off; display/budget stay session-start scoped.',
244
+ options: CLAUDE_THINKING_CONTROL_OPTIONS,
245
+ defaultValue: input.defaults?.thinking ?? 'adaptive',
246
+ availability: 'setup_and_live',
247
+ liveBehavior: 'immediate',
248
+ selectionPolicy: 'inherit',
249
+ },
250
+ {
251
+ id: 'ultracode',
252
+ label: 'Ultracode',
253
+ description: 'Enable Claude Code ultracode mode when the selected model supports it.',
254
+ options: CLAUDE_ULTRACODE_CONTROL_OPTIONS,
255
+ defaultValue: input.defaults?.ultracode ?? 'off',
256
+ availability: 'setup_and_live',
257
+ liveBehavior: 'immediate',
258
+ selectionPolicy: 'inherit',
259
+ },
260
+ ];
214
261
  return buildFirstPartyCodingRuntimeDescriptor({
215
262
  clientType: 'claude-code',
216
263
  models: input.models,
264
+ defaultModel: input.defaults?.model ?? input.models[0]?.value ?? null,
217
265
  workspaces: input.workspaces,
218
266
  workspaceRoots: input.workspaceRoots,
219
267
  executionModes: input.executionModes,
220
268
  permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
221
269
  defaultPermissionMode: 'default',
222
270
  effortOptions: [...CLAUDE_EFFORT_OPTIONS],
271
+ defaultEffort: input.defaults?.effort,
272
+ extraRuntimeControls,
223
273
  streamingTextMode: 'delta',
224
274
  turnModes: [
225
275
  {
@@ -245,26 +295,6 @@ function buildClaudeRuntimeDescriptor(input) {
245
295
  commands,
246
296
  });
247
297
  }
248
- const CLAUDE_EFFORT_VALUES = new Set(CLAUDE_EFFORT_OPTIONS.map((option) => option.value));
249
- function parseClaudePermissionMode(value) {
250
- if (value === 'acceptEdits'
251
- || value === 'auto'
252
- || value === 'bypassPermissions'
253
- || value === 'default'
254
- || value === 'dontAsk'
255
- || value === 'plan') {
256
- return value;
257
- }
258
- return null;
259
- }
260
- function normalizeClaudeEffortLevel(value) {
261
- return typeof value === 'string' && CLAUDE_EFFORT_VALUES.has(value)
262
- ? value
263
- : undefined;
264
- }
265
- async function applyClaudeEffortLevel(q, effort) {
266
- await q.applyFlagSettings({ effortLevel: effort });
267
- }
268
298
  let cachedClaudeCliPath;
269
299
  // The SDK bundles a Bun-compiled `claude` Mach-O binary next to
270
300
  // claude-agent-sdk-darwin-arm64. On some macOS setups that exact binary gets
@@ -354,6 +384,7 @@ async function detectRuntimeModels(cwd) {
354
384
  options: {
355
385
  systemPrompt: { type: 'preset', preset: 'claude_code' },
356
386
  tools: { type: 'preset', preset: 'claude_code' },
387
+ model: 'default',
357
388
  permissionMode: 'default',
358
389
  cwd,
359
390
  settings: { forceLoginMethod: 'claudeai' },
@@ -381,6 +412,7 @@ async function inspectClaudeRuntimeMetadata(cwd, activeQuery) {
381
412
  options: {
382
413
  systemPrompt: { type: 'preset', preset: 'claude_code' },
383
414
  tools: { type: 'preset', preset: 'claude_code' },
415
+ model: 'default',
384
416
  permissionMode: 'default',
385
417
  cwd,
386
418
  settings: { forceLoginMethod: 'claudeai' },
@@ -561,13 +593,22 @@ function buildClaudeRuntimeInfo(input) {
561
593
  tier: 'diagnostic',
562
594
  }
563
595
  : null,
564
- runtimeModels[0]
596
+ input.fallbackModel
565
597
  ? {
566
598
  id: 'fallbackModel',
567
599
  label: 'Fallback model',
568
- value: runtimeModels[0].label,
600
+ value: input.fallbackModel,
569
601
  tier: 'diagnostic',
570
- source: 'host-default',
602
+ source: 'applied',
603
+ }
604
+ : null,
605
+ input.ultracodeEnabled
606
+ ? {
607
+ id: 'ultracode',
608
+ label: 'Ultracode',
609
+ value: 'Enabled',
610
+ tier: 'detail',
611
+ source: 'applied',
571
612
  }
572
613
  : null,
573
614
  {
@@ -808,16 +849,27 @@ function createSession(conversationId, environment, agentId, port, typingSignals
808
849
  };
809
850
  }
810
851
  const claudeCliPath = resolveClaudeCliPath();
852
+ const initialThinkingConfig = claudeThinkingToConfig(config.sessionSettings.thinking);
811
853
  const queryOptions = {
812
854
  systemPrompt: { type: 'preset', preset: 'claude_code' },
813
855
  tools: { type: 'preset', preset: 'claude_code' },
856
+ model: config.model ?? 'default',
814
857
  permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
815
858
  allowDangerouslySkipPermissions: true,
816
859
  cwd,
817
860
  includePartialMessages: true,
818
861
  maxTurns: 200,
819
862
  ...(config.effort ? { effort: config.effort } : {}),
820
- settings: { forceLoginMethod: 'claudeai' },
863
+ ...(initialThinkingConfig ? { thinking: initialThinkingConfig } : {}),
864
+ ...(config.sessionSettings.fallbackModel ? { fallbackModel: config.sessionSettings.fallbackModel } : {}),
865
+ agentProgressSummaries: config.sessionSettings.agentProgressSummaries ?? true,
866
+ ...(config.sessionSettings.agents ? { agents: config.sessionSettings.agents } : {}),
867
+ settings: {
868
+ forceLoginMethod: 'claudeai',
869
+ ...(config.sessionSettings.ultracode
870
+ ? { ultracode: config.sessionSettings.ultracode === 'on' }
871
+ : {}),
872
+ },
821
873
  settingSources: ['project', 'local'],
822
874
  ...(claudeCliPath ? { pathToClaudeCodeExecutable: claudeCliPath } : {}),
823
875
  stderr: (data) => {
@@ -1023,14 +1075,16 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1023
1075
  cwd,
1024
1076
  environment,
1025
1077
  query: q,
1078
+ controlApplySeq: new Map(),
1026
1079
  sendInput,
1027
1080
  enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactRoutingMode),
1028
- state: {
1029
- model: undefined,
1081
+ state: buildClaudeInitialSessionState({
1082
+ model: config.model,
1030
1083
  permissionMode: config.permissionMode,
1031
- effort: undefined,
1032
- state: 'idle',
1033
- },
1084
+ effort: config.effort,
1085
+ fallbackModel: config.sessionSettings.fallbackModel,
1086
+ runtimeControlValues: config.sessionSettings.runtimeControlValues,
1087
+ }),
1034
1088
  availableModels: config.availableModels,
1035
1089
  streamingText: '',
1036
1090
  streamingTimer: null,
@@ -1069,11 +1123,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1069
1123
  capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1070
1124
  fallbackTurnId: `claude-${conversationId}`,
1071
1125
  streamingThrottleMs: STREAMING_THROTTLE_MS,
1072
- requestedControls: {
1073
- model: config.model,
1074
- permissionMode: config.permissionMode,
1075
- effort: config.effort,
1076
- },
1126
+ requestedControls: {},
1077
1127
  }, {
1078
1128
  session: () => session,
1079
1129
  port,
@@ -1089,6 +1139,9 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1089
1139
  formatControlError: formatClaudeControlError,
1090
1140
  });
1091
1141
  const { streamingOutput, flushStreaming, onStreamDelta, clearStreaming, getFinalTurnTrail, writeState, writeTurn, setRuntimeControlError, clearRuntimeControlError, refreshVisibleWorkSignal, startVisibleWorkSignal, stopVisibleWorkSignal, } = writers;
1142
+ // Initial values come from persisted session config and are effective for
1143
+ // this query. Publish immediately so controls do not linger as requested.
1144
+ writeState();
1092
1145
  function isActiveTurnInterrupted() {
1093
1146
  const turnKey = session.activeInput?.turnKey;
1094
1147
  return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
@@ -1213,6 +1266,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1213
1266
  };
1214
1267
  writeTurn();
1215
1268
  // ── Process SDK output messages ──
1269
+ let assistantStreamOrdinal = 0;
1216
1270
  const processMessages = async () => {
1217
1271
  try {
1218
1272
  for await (const msg of q) {
@@ -1243,7 +1297,10 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1243
1297
  stopVisibleWorkSignal();
1244
1298
  break;
1245
1299
  }
1246
- if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
1300
+ if (event?.type === 'message_start') {
1301
+ assistantStreamOrdinal += 1;
1302
+ }
1303
+ else if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
1247
1304
  session.toolInProgress = true;
1248
1305
  session.turnState = 'tool';
1249
1306
  streamingOutput.addBlock({
@@ -1272,12 +1329,35 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1272
1329
  stopVisibleWorkSignal();
1273
1330
  writeTurn();
1274
1331
  const blockIndex = typeof event.index === 'number' ? event.index : 'latest';
1275
- onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${blockIndex}`);
1332
+ const messageOrdinal = assistantStreamOrdinal || 1;
1333
+ onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${messageOrdinal}:${blockIndex}`);
1276
1334
  }
1277
1335
  break;
1278
1336
  }
1279
1337
  case 'system': {
1280
1338
  const subtype = msg.subtype;
1339
+ if (subtype === 'task_started'
1340
+ || subtype === 'task_progress'
1341
+ || subtype === 'task_updated'
1342
+ || subtype === 'task_notification') {
1343
+ const activity = mapClaudeSubagentActivity(msg);
1344
+ if (activity && !isActiveTurnInterrupted()) {
1345
+ if (!session.currentTurnId) {
1346
+ session.currentTurnId = randomUUID();
1347
+ session.currentTurnOpenedAt = Date.now();
1348
+ }
1349
+ if (session.state.state === 'running' && session.turnState !== 'streaming') {
1350
+ session.turnState = 'thinking';
1351
+ startVisibleWorkSignal();
1352
+ writeTurn();
1353
+ }
1354
+ await streamingOutput.addBlock(activity).catch(() => { });
1355
+ }
1356
+ }
1357
+ if (subtype === 'model_refusal_fallback') {
1358
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Model refusal fallback: `
1359
+ + `${msg.original_model} → ${msg.fallback_model}`);
1360
+ }
1281
1361
  if (subtype === 'session_state_changed') {
1282
1362
  const state = msg.state;
1283
1363
  session.state.state = state;
@@ -1289,6 +1369,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1289
1369
  }
1290
1370
  session.state.contextUsage = undefined;
1291
1371
  session.pendingFinalText = null;
1372
+ assistantStreamOrdinal = 0;
1292
1373
  session.toolInProgress = false;
1293
1374
  session.turnState = 'thinking';
1294
1375
  startVisibleWorkSignal();
@@ -1385,24 +1466,6 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1385
1466
  const init = async () => {
1386
1467
  processMessages().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] processMessages crashed:`, err));
1387
1468
  await q.initializationResult();
1388
- if (config.model) {
1389
- try {
1390
- await q.setModel(config.model);
1391
- session.state.model = config.model;
1392
- }
1393
- catch (err) {
1394
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial model:`, err);
1395
- }
1396
- }
1397
- if (config.effort) {
1398
- try {
1399
- await applyClaudeEffortLevel(q, config.effort);
1400
- session.state.effort = config.effort;
1401
- }
1402
- catch (err) {
1403
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial effort:`, err);
1404
- }
1405
- }
1406
1469
  if (!resumeSessionId) {
1407
1470
  // New session — send seed message to activate streaming input mode
1408
1471
  sendInput(createClaudeInputEnvelope({
@@ -1417,25 +1480,32 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1417
1480
  },
1418
1481
  }));
1419
1482
  }
1420
- // Refresh the canonical runtime descriptor after Claude reports supported models.
1483
+ // Refresh the canonical runtime descriptor from a neutral probe. The live
1484
+ // session may be initialized with a selected model, which can narrow the
1485
+ // SDK's cached model list.
1421
1486
  try {
1422
- const models = await q.supportedModels();
1423
- const modelList = mergeClaudeModelOptions({
1424
- previous: runtimeModels,
1425
- next: toModelOptions(models),
1487
+ const discoveredModels = await detectRuntimeModels(cwd).catch((error) => {
1488
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Model discovery failed:`, error);
1489
+ return runtimeModels;
1490
+ });
1491
+ if (discoveredModels.length > 0) {
1492
+ runtimeModels = discoveredModels;
1493
+ }
1494
+ const modelList = resolveClaudeModelOptions({
1495
+ discovered: runtimeModels,
1426
1496
  selectedModel: session.state.model,
1427
1497
  });
1428
1498
  session.availableModels = modelList;
1429
- runtimeModels = modelList;
1430
1499
  onRuntimeDescriptorUpdate({
1431
1500
  defaultWorkspaceId: workspaceOptions[0]?.id,
1432
- defaultModel: modelList[0]?.value,
1501
+ defaultModel: config.sessionSettings.model ?? modelList[0]?.value,
1433
1502
  defaultPermissionMode: 'default',
1434
1503
  availableModels: modelList,
1435
1504
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1436
1505
  availableExecutionModes: config.availableExecutionModes,
1437
1506
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1438
1507
  models: modelList,
1508
+ defaults: config.sessionSettings,
1439
1509
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1440
1510
  workspaceRoots: workspaceRootMetadata,
1441
1511
  executionModes: config.availableExecutionModes,
@@ -1443,7 +1513,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
1443
1513
  }),
1444
1514
  });
1445
1515
  writeState();
1446
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${models.map((m) => m.displayName).join(', ')}`);
1516
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${modelList.map((m) => m.label).join(', ')}`);
1447
1517
  }
1448
1518
  catch {
1449
1519
  writeState();
@@ -1488,7 +1558,8 @@ export async function main() {
1488
1558
  for (const warning of workspaceDiscovery.warnings) {
1489
1559
  console.error(`[canon-host] ${warning}`);
1490
1560
  }
1491
- const { agentName: profileAgentName, profile, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
1561
+ const { agentName: profileAgentName, profile, sessionDefaults: rawSessionDefaults, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
1562
+ const profileSessionDefaults = parseClaudeSessionDefaults(rawSessionDefaults);
1492
1563
  activeLockHandle = lockHandle ?? null;
1493
1564
  console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
1494
1565
  // ── Canon via the Bridge (bridge-execution-plan Phase 6) ──
@@ -1578,12 +1649,14 @@ export async function main() {
1578
1649
  });
1579
1650
  let runtimeDescriptor = {
1580
1651
  defaultWorkspaceId: workspaceOptions[0]?.id,
1652
+ defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1581
1653
  defaultPermissionMode: 'default',
1582
1654
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1583
1655
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1584
1656
  availableExecutionModes: hostAvailableExecutionModes,
1585
1657
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1586
1658
  models: runtimeModels,
1659
+ defaults: profileSessionDefaults,
1587
1660
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1588
1661
  workspaceRoots: workspaceRootMetadata,
1589
1662
  executionModes: hostAvailableExecutionModes,
@@ -1598,31 +1671,12 @@ export async function main() {
1598
1671
  refreshFloorMs: HEARTBEAT_MS,
1599
1672
  }, { port });
1600
1673
  const { conversationCache, knownConversationIds, getConversationMeta, refreshKnownConversationIds, handleConversationUpdated, markGroupContextModeUsed, loadHydratedInboundContext, } = directory;
1601
- const publishRuntimeHeartbeat = async () => {
1602
- heartbeatLocalRuntimeEntry(runtimeId, {
1603
- agentId,
1604
- agentName: profileAgentName,
1605
- cwd: workingDir,
1606
- baseCwd: workingDir,
1607
- });
1608
- if (!streamConnected)
1674
+ const publishSessionSnapshotsForConversations = async (conversationIds) => {
1675
+ const ids = Array.from(new Set(conversationIds));
1676
+ if (!streamConnected || ids.length === 0)
1609
1677
  return;
1610
- await refreshKnownConversationIds().catch((error) => {
1611
- console.error('[canon-host] Failed to refresh known conversations:', error);
1612
- });
1613
- const activeSessionQuery = sessions.values().next().value?.query ?? null;
1614
- const metadata = await refreshClaudeRuntimeMetadata({
1615
- cwd: workingDir,
1616
- activeQuery: activeSessionQuery,
1617
- }).catch((error) => {
1618
- console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
1619
- return runtimeMetadata;
1620
- });
1621
- await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
1622
- console.error('[canon-host] Failed to publish agent runtime:', error);
1623
- });
1624
1678
  await port.publishHostSessionSnapshots({
1625
- conversationIds: Array.from(knownConversationIds),
1679
+ conversationIds: ids,
1626
1680
  clientType: 'claude-code',
1627
1681
  runtime: runtimeDescriptor,
1628
1682
  workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
@@ -1636,6 +1690,9 @@ export async function main() {
1636
1690
  ...(session.state.model ? { model: session.state.model } : {}),
1637
1691
  ...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
1638
1692
  ...(session.state.effort ? { effort: session.state.effort } : {}),
1693
+ ...(session.state.runtimeControlValues
1694
+ ? { runtimeControlValues: session.state.runtimeControlValues }
1695
+ : {}),
1639
1696
  ...(workspaceId ? { workspaceId } : {}),
1640
1697
  executionMode: session.environment.mode,
1641
1698
  executionBranch: session.environment.branch ?? null,
@@ -1645,14 +1702,41 @@ export async function main() {
1645
1702
  }).catch((error) => {
1646
1703
  console.error('[canon-host] Failed to publish session snapshots:', error);
1647
1704
  });
1705
+ };
1706
+ const publishRuntimeHeartbeat = async () => {
1707
+ heartbeatLocalRuntimeEntry(runtimeId, {
1708
+ agentId,
1709
+ agentName: profileAgentName,
1710
+ cwd: workingDir,
1711
+ baseCwd: workingDir,
1712
+ });
1713
+ if (!streamConnected)
1714
+ return;
1715
+ await refreshKnownConversationIds().catch((error) => {
1716
+ console.error('[canon-host] Failed to refresh known conversations:', error);
1717
+ });
1718
+ const activeSessionQuery = sessions.values().next().value?.query ?? null;
1719
+ const metadata = await refreshClaudeRuntimeMetadata({
1720
+ cwd: workingDir,
1721
+ activeQuery: activeSessionQuery,
1722
+ }).catch((error) => {
1723
+ console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
1724
+ return runtimeMetadata;
1725
+ });
1726
+ await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
1727
+ console.error('[canon-host] Failed to publish agent runtime:', error);
1728
+ });
1729
+ await publishSessionSnapshotsForConversations(knownConversationIds);
1648
1730
  // Rebuild so the descriptor picks up native slash commands from the
1649
1731
  // metadata refresh above — the initial descriptor is built before the
1650
1732
  // command inventory has loaded.
1651
1733
  if (runtimeDescriptor.runtimeDescriptor) {
1652
1734
  runtimeDescriptor = {
1653
1735
  ...runtimeDescriptor,
1736
+ defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1654
1737
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1655
1738
  models: runtimeModels,
1739
+ defaults: profileSessionDefaults,
1656
1740
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1657
1741
  workspaceRoots: workspaceRootMetadata,
1658
1742
  executionModes: hostAvailableExecutionModes,
@@ -1680,6 +1764,10 @@ export async function main() {
1680
1764
  executionBranch: session?.environment.branch ?? null,
1681
1765
  worktreePath: session?.environment.worktreePath ?? null,
1682
1766
  fallbackReason: resolveExecutionFallbackReason(session?.environment),
1767
+ fallbackModel: session?.state.fallbackModel
1768
+ ?? profileSessionDefaults.fallbackModel
1769
+ ?? null,
1770
+ ultracodeEnabled: (session?.state.runtimeControlValues?.ultracode ?? profileSessionDefaults.ultracode) === 'on',
1683
1771
  });
1684
1772
  await port.publishRuntimeStatus({
1685
1773
  conversationId,
@@ -1694,7 +1782,7 @@ export async function main() {
1694
1782
  runtimeModels = await detectRuntimeModels(workingDir);
1695
1783
  runtimeDescriptor = {
1696
1784
  defaultWorkspaceId: workspaceOptions[0]?.id,
1697
- defaultModel: runtimeModels[0]?.value,
1785
+ defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1698
1786
  defaultPermissionMode: 'default',
1699
1787
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1700
1788
  availableModels: runtimeModels,
@@ -1702,6 +1790,7 @@ export async function main() {
1702
1790
  availableExecutionModes: hostAvailableExecutionModes,
1703
1791
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1704
1792
  models: runtimeModels,
1793
+ defaults: profileSessionDefaults,
1705
1794
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1706
1795
  workspaceRoots: workspaceRootMetadata,
1707
1796
  executionModes: hostAvailableExecutionModes,
@@ -1712,12 +1801,14 @@ export async function main() {
1712
1801
  catch {
1713
1802
  runtimeDescriptor = {
1714
1803
  defaultWorkspaceId: workspaceOptions[0]?.id,
1804
+ defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
1715
1805
  defaultPermissionMode: 'default',
1716
1806
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
1717
1807
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1718
1808
  availableExecutionModes: hostAvailableExecutionModes,
1719
1809
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
1720
1810
  models: runtimeModels,
1811
+ defaults: profileSessionDefaults,
1721
1812
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1722
1813
  workspaceRoots: workspaceRootMetadata,
1723
1814
  executionModes: hostAvailableExecutionModes,
@@ -1847,6 +1938,10 @@ export async function main() {
1847
1938
  function createSessionForConversation(conversationId) {
1848
1939
  return (async () => {
1849
1940
  const config = await loadSessionConfig(port, conversationId);
1941
+ const sessionSettings = resolveEffectiveSessionSettings({
1942
+ profileDefaults: profileSessionDefaults,
1943
+ sessionConfig: config,
1944
+ });
1850
1945
  const sessionExecutionMode = resolveSessionExecutionMode(config);
1851
1946
  const workspaceCwd = resolveWorkspaceCwd(config);
1852
1947
  const environment = prepareConversationEnvironment({
@@ -1894,9 +1989,10 @@ export async function main() {
1894
1989
  runtimeDescriptor = descriptor;
1895
1990
  void publishRuntimeHeartbeat();
1896
1991
  }, {
1897
- model: config?.model ?? runtimeModels[0]?.value,
1992
+ model: sessionSettings.model ?? runtimeModels[0]?.value,
1898
1993
  permissionMode: config?.permissionMode ?? 'default',
1899
- effort: config?.effort,
1994
+ effort: sessionSettings.effort,
1995
+ sessionSettings,
1900
1996
  availableModels: runtimeModels,
1901
1997
  availableExecutionModes: hostAvailableExecutionModes,
1902
1998
  runtimePresentation,
@@ -2252,62 +2348,19 @@ export async function main() {
2252
2348
  },
2253
2349
  });
2254
2350
  // ── Control signal watcher ──
2255
- // Session control signals (model, mode, effort). A missing/closed session
2256
- // leaves the node in place; lastSeen still advances, so a stale control is
2257
- // never replayed once a session exists.
2351
+ // Session control signals (model, mode, effort). The bridge has already
2352
+ // consumed the control notification, so every path must publish an answer.
2258
2353
  async function applyRuntimeSessionControl(event) {
2259
2354
  const convoId = event.conversationId;
2260
2355
  const session = sessions.get(convoId);
2261
- if (!session || session.closed)
2262
- return { consume: false };
2356
+ if (!session || session.closed) {
2357
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Control received without live session; publishing persisted snapshot`);
2358
+ void publishSessionSnapshotsForConversations([convoId]);
2359
+ return;
2360
+ }
2263
2361
  const ctrl = event.control;
2264
2362
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
2265
- if (ctrl.model && ctrl.model !== session.state.model) {
2266
- try {
2267
- await session.query.setModel(ctrl.model);
2268
- session.state.model = ctrl.model;
2269
- session.clearRuntimeControlError('model');
2270
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Model → ${ctrl.model}`);
2271
- }
2272
- catch (err) {
2273
- session.setRuntimeControlError('model', ctrl.model, err);
2274
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
2275
- }
2276
- }
2277
- const nextPermissionMode = parseClaudePermissionMode(ctrl.permissionMode);
2278
- if (ctrl.permissionMode && !nextPermissionMode) {
2279
- session.setRuntimeControlError('permissionMode', ctrl.permissionMode, new Error(`Unsupported Claude permission mode: ${ctrl.permissionMode}`));
2280
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Unsupported mode: ${ctrl.permissionMode}`);
2281
- }
2282
- else if (nextPermissionMode && nextPermissionMode !== session.state.permissionMode) {
2283
- try {
2284
- await session.query.setPermissionMode(nextPermissionMode);
2285
- session.state.permissionMode = nextPermissionMode;
2286
- session.clearRuntimeControlError('permissionMode');
2287
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${nextPermissionMode}`);
2288
- }
2289
- catch (err) {
2290
- session.setRuntimeControlError('permissionMode', nextPermissionMode, err);
2291
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
2292
- }
2293
- }
2294
- const nextEffort = normalizeClaudeEffortLevel(ctrl.effort);
2295
- if (nextEffort && nextEffort !== session.state.effort) {
2296
- try {
2297
- await applyClaudeEffortLevel(session.query, nextEffort);
2298
- session.state.effort = nextEffort;
2299
- session.clearRuntimeControlError('effort');
2300
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Effort → ${nextEffort}`);
2301
- }
2302
- catch (err) {
2303
- session.setRuntimeControlError('effort', nextEffort, err);
2304
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err);
2305
- }
2306
- }
2307
- else if (ctrl.effort && !nextEffort) {
2308
- session.setRuntimeControlError('effort', String(ctrl.effort), new Error('Unsupported Claude thinking level.'));
2309
- }
2310
- session.writeState();
2363
+ applyClaudeSessionControl(session, ctrl);
2311
2364
  }
2312
2365
  // ── Heartbeat + idle cleanup + graceful shutdown (agent-host lifecycle) ──
2313
2366
  lifecycle.startTimers();
@@ -0,0 +1,35 @@
1
+ import type { AgentDefinition, EffortLevel, ThinkingConfig } from '@anthropic-ai/claude-agent-sdk';
2
+ export type ClaudeThinkingMode = 'adaptive' | 'off';
3
+ export type ClaudeUltracodeMode = 'on' | 'off';
4
+ export type ClaudeEffortLevel = Extract<EffortLevel, 'low' | 'medium' | 'high' | 'xhigh' | 'max'>;
5
+ export interface ClaudeSessionDefaults {
6
+ model?: string;
7
+ effort?: ClaudeEffortLevel;
8
+ thinking?: ClaudeThinkingMode;
9
+ ultracode?: ClaudeUltracodeMode;
10
+ fallbackModel?: string;
11
+ agentProgressSummaries?: boolean;
12
+ agents?: Record<string, AgentDefinition>;
13
+ }
14
+ export interface ClaudeSessionControlValues {
15
+ thinking?: ClaudeThinkingMode;
16
+ ultracode?: ClaudeUltracodeMode;
17
+ }
18
+ export interface ClaudeEffectiveSessionSettings extends ClaudeSessionDefaults {
19
+ runtimeControlValues: Record<string, string>;
20
+ }
21
+ export declare const CLAUDE_EFFORT_LEVELS: readonly ClaudeEffortLevel[];
22
+ export declare function parseClaudeThinkingMode(value: unknown): ClaudeThinkingMode | undefined;
23
+ export declare function parseClaudeUltracodeMode(value: unknown): ClaudeUltracodeMode | undefined;
24
+ export declare function parseClaudeSessionDefaults(raw: unknown): ClaudeSessionDefaults;
25
+ export declare function parseClaudeSessionControlValues(values: Record<string, string> | null | undefined): ClaudeSessionControlValues;
26
+ export declare function claudeThinkingToConfig(mode: ClaudeThinkingMode | undefined): ThinkingConfig | undefined;
27
+ export declare function claudeThinkingToFlagValue(mode: ClaudeThinkingMode): boolean;
28
+ export declare function resolveEffectiveSessionSettings(input: {
29
+ profileDefaults?: ClaudeSessionDefaults;
30
+ sessionConfig?: {
31
+ model?: string;
32
+ effort?: ClaudeEffortLevel;
33
+ runtimeControlValues?: Record<string, string>;
34
+ } | null;
35
+ }): ClaudeEffectiveSessionSettings;
@@ -0,0 +1,124 @@
1
+ export const CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
2
+ const CLAUDE_EFFORT_VALUES = new Set(CLAUDE_EFFORT_LEVELS);
3
+ function optionalString(value) {
4
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
5
+ }
6
+ function optionalBoolean(value) {
7
+ return typeof value === 'boolean' ? value : undefined;
8
+ }
9
+ export function parseClaudeThinkingMode(value) {
10
+ if (value === false || value === 'off' || value === 'disabled')
11
+ return 'off';
12
+ if (value === true || value === 'on' || value === 'adaptive' || value === 'enabled')
13
+ return 'adaptive';
14
+ return undefined;
15
+ }
16
+ export function parseClaudeUltracodeMode(value) {
17
+ if (value === true || value === 'on' || value === 'enabled')
18
+ return 'on';
19
+ if (value === false || value === 'off' || value === 'disabled')
20
+ return 'off';
21
+ return undefined;
22
+ }
23
+ function parseEffort(value) {
24
+ const normalized = optionalString(value);
25
+ if (!normalized)
26
+ return undefined;
27
+ return CLAUDE_EFFORT_VALUES.has(normalized)
28
+ ? normalized
29
+ : undefined;
30
+ }
31
+ function parseStringArray(value) {
32
+ if (!Array.isArray(value))
33
+ return undefined;
34
+ const out = value
35
+ .map((entry) => optionalString(entry))
36
+ .filter((entry) => Boolean(entry));
37
+ return out.length > 0 ? out : undefined;
38
+ }
39
+ function parseAgentDefinition(value) {
40
+ if (!value || typeof value !== 'object' || Array.isArray(value))
41
+ return null;
42
+ const data = value;
43
+ const description = optionalString(data.description);
44
+ const prompt = optionalString(data.prompt);
45
+ if (!description || !prompt)
46
+ return null;
47
+ return {
48
+ description,
49
+ prompt,
50
+ ...(parseStringArray(data.tools) ? { tools: parseStringArray(data.tools) } : {}),
51
+ ...(parseStringArray(data.disallowedTools) ? { disallowedTools: parseStringArray(data.disallowedTools) } : {}),
52
+ ...(optionalString(data.model) ? { model: optionalString(data.model) } : {}),
53
+ ...(parseStringArray(data.skills) ? { skills: parseStringArray(data.skills) } : {}),
54
+ ...(typeof data.maxTurns === 'number' && Number.isFinite(data.maxTurns) && data.maxTurns > 0
55
+ ? { maxTurns: Math.floor(data.maxTurns) }
56
+ : {}),
57
+ ...(typeof data.background === 'boolean' ? { background: data.background } : {}),
58
+ ...(parseEffort(data.effort) ? { effort: parseEffort(data.effort) } : {}),
59
+ };
60
+ }
61
+ function parseAgents(value) {
62
+ if (!value || typeof value !== 'object' || Array.isArray(value))
63
+ return undefined;
64
+ const out = {};
65
+ for (const [rawName, rawAgent] of Object.entries(value)) {
66
+ const name = rawName.trim();
67
+ if (!name)
68
+ continue;
69
+ const agent = parseAgentDefinition(rawAgent);
70
+ if (agent)
71
+ out[name] = agent;
72
+ }
73
+ return Object.keys(out).length > 0 ? out : undefined;
74
+ }
75
+ export function parseClaudeSessionDefaults(raw) {
76
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
77
+ return {};
78
+ const data = raw;
79
+ return {
80
+ ...(optionalString(data.model) ? { model: optionalString(data.model) } : {}),
81
+ ...(parseEffort(data.effort) ? { effort: parseEffort(data.effort) } : {}),
82
+ ...(parseClaudeThinkingMode(data.thinking) ? { thinking: parseClaudeThinkingMode(data.thinking) } : {}),
83
+ ...(parseClaudeUltracodeMode(data.ultracode) ? { ultracode: parseClaudeUltracodeMode(data.ultracode) } : {}),
84
+ ...(optionalString(data.fallbackModel) ? { fallbackModel: optionalString(data.fallbackModel) } : {}),
85
+ ...(optionalBoolean(data.agentProgressSummaries) !== undefined
86
+ ? { agentProgressSummaries: optionalBoolean(data.agentProgressSummaries) }
87
+ : {}),
88
+ ...(parseAgents(data.agents) ? { agents: parseAgents(data.agents) } : {}),
89
+ };
90
+ }
91
+ export function parseClaudeSessionControlValues(values) {
92
+ return {
93
+ ...(parseClaudeThinkingMode(values?.thinking) ? { thinking: parseClaudeThinkingMode(values?.thinking) } : {}),
94
+ ...(parseClaudeUltracodeMode(values?.ultracode) ? { ultracode: parseClaudeUltracodeMode(values?.ultracode) } : {}),
95
+ };
96
+ }
97
+ export function claudeThinkingToConfig(mode) {
98
+ if (mode === 'adaptive')
99
+ return { type: 'adaptive' };
100
+ if (mode === 'off')
101
+ return { type: 'disabled' };
102
+ return undefined;
103
+ }
104
+ export function claudeThinkingToFlagValue(mode) {
105
+ return mode !== 'off';
106
+ }
107
+ export function resolveEffectiveSessionSettings(input) {
108
+ const controlValues = parseClaudeSessionControlValues(input.sessionConfig?.runtimeControlValues);
109
+ const runtimeControlValues = {
110
+ ...(input.profileDefaults?.thinking ? { thinking: input.profileDefaults.thinking } : {}),
111
+ ...(input.profileDefaults?.ultracode ? { ultracode: input.profileDefaults.ultracode } : {}),
112
+ ...(input.sessionConfig?.runtimeControlValues ?? {}),
113
+ ...(controlValues.thinking ? { thinking: controlValues.thinking } : {}),
114
+ ...(controlValues.ultracode ? { ultracode: controlValues.ultracode } : {}),
115
+ };
116
+ return {
117
+ ...(input.profileDefaults ?? {}),
118
+ ...(input.sessionConfig?.model ? { model: input.sessionConfig.model } : {}),
119
+ ...(input.sessionConfig?.effort ? { effort: input.sessionConfig.effort } : {}),
120
+ ...(controlValues.thinking ? { thinking: controlValues.thinking } : {}),
121
+ ...(controlValues.ultracode ? { ultracode: controlValues.ultracode } : {}),
122
+ runtimeControlValues,
123
+ };
124
+ }
@@ -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,8 @@ 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 declare function resolveClaudeModelOptions(input: {
107
+ discovered: ReadonlyArray<ModelOption>;
66
108
  selectedModel?: string | null;
67
109
  }): ModelOption[];
68
110
  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,19 @@ export function resetClaudeCompletedTurnState(session) {
46
215
  session.toolInProgress = false;
47
216
  session.turnState = 'idle';
48
217
  }
49
- export function mergeClaudeModelOptions(input) {
218
+ export function resolveClaudeModelOptions(input) {
50
219
  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) {
220
+ for (const option of input.discovered) {
55
221
  byValue.set(option.value, { ...option });
56
222
  }
57
223
  if (input.selectedModel && !byValue.has(input.selectedModel)) {
58
224
  byValue.set(input.selectedModel, {
59
225
  value: input.selectedModel,
60
226
  label: input.selectedModel,
61
- description: 'Selected for this Canon session but not returned by the latest Claude model probe.',
227
+ description: 'Selected for this Canon session but not returned by Claude model discovery.',
62
228
  });
63
229
  }
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
- });
230
+ return [...byValue.values()];
75
231
  }
76
232
  export function formatClaudeControlError(error) {
77
233
  if (error instanceof Error && error.message.trim()) {
@@ -0,0 +1,11 @@
1
+ import type { SDKTaskNotificationMessage, SDKTaskProgressMessage, SDKTaskStartedMessage, SDKTaskUpdatedMessage } from '@anthropic-ai/claude-agent-sdk';
2
+ type ClaudeTaskMessage = SDKTaskNotificationMessage | SDKTaskProgressMessage | SDKTaskStartedMessage | SDKTaskUpdatedMessage;
3
+ export interface ClaudeSubagentActivityBlock {
4
+ id: string;
5
+ kind: 'status';
6
+ status: 'running' | 'completed' | 'failed';
7
+ title: string;
8
+ text?: string;
9
+ }
10
+ export declare function mapClaudeSubagentActivity(message: ClaudeTaskMessage): ClaudeSubagentActivityBlock | null;
11
+ export {};
@@ -0,0 +1,51 @@
1
+ function titleForMessage(message) {
2
+ const subtype = message.subtype;
3
+ if (subtype === 'task_started') {
4
+ return message.subagent_type
5
+ ? `Subagent started: ${message.subagent_type}`
6
+ : 'Subagent started';
7
+ }
8
+ if (subtype === 'task_progress') {
9
+ return message.subagent_type
10
+ ? `Subagent progress: ${message.subagent_type}`
11
+ : 'Subagent progress';
12
+ }
13
+ if (subtype === 'task_updated')
14
+ return 'Subagent updated';
15
+ return `Subagent ${message.status}`;
16
+ }
17
+ function textForMessage(message) {
18
+ if (message.subtype === 'task_started')
19
+ return message.description;
20
+ if (message.subtype === 'task_progress')
21
+ return message.summary ?? message.description;
22
+ if (message.subtype === 'task_updated')
23
+ return message.patch.description ?? message.patch.error;
24
+ return message.summary;
25
+ }
26
+ function statusForMessage(message) {
27
+ if (message.subtype === 'task_notification') {
28
+ if (message.status === 'completed')
29
+ return 'completed';
30
+ return 'failed';
31
+ }
32
+ if (message.subtype === 'task_updated') {
33
+ if (message.patch.status === 'completed')
34
+ return 'completed';
35
+ if (message.patch.status === 'failed' || message.patch.status === 'killed')
36
+ return 'failed';
37
+ }
38
+ return 'running';
39
+ }
40
+ export function mapClaudeSubagentActivity(message) {
41
+ if ('skip_transcript' in message && message.skip_transcript)
42
+ return null;
43
+ const text = textForMessage(message);
44
+ return {
45
+ id: `task:${message.task_id}`,
46
+ kind: 'status',
47
+ status: statusForMessage(message),
48
+ title: titleForMessage(message),
49
+ ...(text ? { text } : {}),
50
+ };
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.20.0",
3
+ "version": "0.21.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",
@@ -31,10 +31,10 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "^0.3.200",
34
- "@canonmsg/agent-host": "^0.3.0",
35
- "@canonmsg/agent-sdk": "^3.4.3",
34
+ "@canonmsg/agent-host": "^0.4.0",
35
+ "@canonmsg/agent-sdk": "^3.4.4",
36
36
  "@canonmsg/bridge": "^0.2.0",
37
- "@canonmsg/core": "^3.1.0",
37
+ "@canonmsg/core": "^3.2.0",
38
38
  "@canonmsg/framework": "^0.2.0",
39
39
  "@modelcontextprotocol/sdk": "^1.29.0"
40
40
  },