@canonmsg/claude-code-plugin 0.23.0 → 0.24.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.
package/dist/host.js CHANGED
@@ -33,7 +33,7 @@ import { decideAutoReply, } from './inbound-policy.js';
33
33
  import { runCli } from './cli-entry.js';
34
34
  import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
35
35
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
36
- import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
36
+ import { applyClaudeSessionControl, buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
37
37
  function parseRuntimeVisibilityPreset(value) {
38
38
  return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
39
39
  }
@@ -195,6 +195,26 @@ function buildClaudeRuntimeDescriptor(input) {
195
195
  controlId: 'effort',
196
196
  },
197
197
  },
198
+ {
199
+ id: 'ultracode-details',
200
+ label: 'Ultracode',
201
+ description: 'Toggle Claude ultracode using /ultracode.',
202
+ aliases: ['ultracode'],
203
+ category: 'runtime',
204
+ placements: ['composer_slash', 'command_palette'],
205
+ availability: ['always'],
206
+ args: [{
207
+ id: 'level',
208
+ label: 'Level',
209
+ kind: 'enum',
210
+ required: true,
211
+ choices: [...CLAUDE_ULTRACODE_OPTIONS],
212
+ }],
213
+ dispatch: {
214
+ kind: 'control',
215
+ controlId: 'ultracode',
216
+ },
217
+ },
198
218
  {
199
219
  ...RUNTIME_NEW_SESSION_ACTION,
200
220
  primitive: 'session.new',
@@ -212,6 +232,15 @@ function buildClaudeRuntimeDescriptor(input) {
212
232
  permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
213
233
  defaultPermissionMode: 'default',
214
234
  effortOptions: [...CLAUDE_EFFORT_OPTIONS],
235
+ extraRuntimeControls: [{
236
+ id: 'ultracode',
237
+ label: 'Ultracode',
238
+ options: [...CLAUDE_ULTRACODE_OPTIONS],
239
+ defaultValue: 'off',
240
+ availability: 'setup_and_live',
241
+ liveBehavior: 'immediate',
242
+ selectionPolicy: 'inherit',
243
+ }],
215
244
  streamingTextMode: 'delta',
216
245
  turnModes: [
217
246
  {
@@ -257,6 +286,16 @@ function normalizeClaudeEffortLevel(value) {
257
286
  async function applyClaudeEffortLevel(q, effort) {
258
287
  await q.applyFlagSettings({ effortLevel: effort });
259
288
  }
289
+ const CLAUDE_ULTRACODE_OPTIONS = [
290
+ { value: 'on', label: 'On' },
291
+ { value: 'off', label: 'Off' },
292
+ ];
293
+ function parseClaudeUltracodeMode(value) {
294
+ return value === 'on' || value === 'off' ? value : undefined;
295
+ }
296
+ async function applyClaudeUltracode(q, enabled) {
297
+ await q.applyFlagSettings({ ultracode: enabled });
298
+ }
260
299
  let cachedClaudeCliPath;
261
300
  // The SDK bundles a Bun-compiled `claude` Mach-O binary next to
262
301
  // claude-agent-sdk-darwin-arm64. On some macOS setups that exact binary gets
@@ -314,6 +353,7 @@ async function loadSessionConfig(conversationId, agentId) {
314
353
  ? parseClaudePermissionMode(config.permissionMode) ?? undefined
315
354
  : undefined,
316
355
  effort: normalizeClaudeEffortLevel(config?.effort),
356
+ ultracode: parseClaudeUltracodeMode(config?.runtimeControlValues?.ultracode),
317
357
  };
318
358
  }
319
359
  function resolveSessionExecutionMode(config) {
@@ -988,6 +1028,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
988
1028
  if (session.state.permissionMode === 'plan') {
989
1029
  session.state.permissionMode = 'default';
990
1030
  writeState();
1031
+ // Converge the app immediately on the SDK-driven plan-mode exit
1032
+ // instead of waiting for the next 30s heartbeat (lag fix).
1033
+ config.publishSessionSnapshots?.([conversationId]);
991
1034
  }
992
1035
  return { behavior: 'allow' };
993
1036
  }
@@ -1056,6 +1099,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1056
1099
  model: undefined,
1057
1100
  permissionMode: config.permissionMode,
1058
1101
  effort: undefined,
1102
+ ultracode: undefined,
1059
1103
  state: 'idle',
1060
1104
  },
1061
1105
  availableModels: config.availableModels,
@@ -1324,10 +1368,17 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1324
1368
  else if (config.effort !== undefined) {
1325
1369
  controlState.effort = { value: config.effort, source: 'requested' };
1326
1370
  }
1371
+ if (session.state.ultracode !== undefined) {
1372
+ controlState.ultracode = { value: session.state.ultracode, source: 'applied', appliedAt };
1373
+ }
1374
+ else if (config.ultracode !== undefined) {
1375
+ controlState.ultracode = { value: config.ultracode, source: 'requested' };
1376
+ }
1327
1377
  runtimeState.writeSessionState(conversationId, {
1328
1378
  model: session.state.model,
1329
1379
  permissionMode: session.state.permissionMode,
1330
1380
  effort: session.state.effort,
1381
+ ...(session.state.ultracode ? { runtimeControlValues: { ultracode: session.state.ultracode } } : {}),
1331
1382
  controlState,
1332
1383
  runtimeControlErrors,
1333
1384
  contextUsage: session.state.contextUsage,
@@ -1666,6 +1717,15 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1666
1717
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial effort:`, err);
1667
1718
  }
1668
1719
  }
1720
+ if (config.ultracode) {
1721
+ try {
1722
+ await applyClaudeUltracode(q, config.ultracode === 'on');
1723
+ session.state.ultracode = config.ultracode;
1724
+ }
1725
+ catch (err) {
1726
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial ultracode:`, err);
1727
+ }
1728
+ }
1669
1729
  if (!resumeSessionId) {
1670
1730
  // New session — send seed message to activate streaming input mode
1671
1731
  sendInput(createClaudeInputEnvelope({
@@ -1848,31 +1908,15 @@ export async function main() {
1848
1908
  const promptedGroupContextConversationIds = new Set();
1849
1909
  const pendingMembershipChanges = new Map();
1850
1910
  let lastKnownConversationRefreshAt = 0;
1851
- const publishRuntimeHeartbeat = async () => {
1852
- heartbeatLocalRuntimeEntry(runtimeId, {
1853
- agentId,
1854
- agentName: profileAgentName,
1855
- cwd: workingDir,
1856
- baseCwd: workingDir,
1857
- });
1858
- if (!streamConnected)
1911
+ // Publish the app-subscribed /agent-session snapshot for the given
1912
+ // conversations. Extracted from the heartbeat so control handlers can converge
1913
+ // the app immediately instead of waiting for the next 30s heartbeat
1914
+ // (session-control lag fix). Hoisted so it is reachable from earlier code.
1915
+ async function publishSessionSnapshots(conversationIds) {
1916
+ if (!streamConnected || conversationIds.length === 0)
1859
1917
  return;
1860
- await refreshKnownConversationIds().catch((error) => {
1861
- console.error('[canon-host] Failed to refresh known conversations:', error);
1862
- });
1863
- const activeSessionQuery = sessions.values().next().value?.query ?? null;
1864
- const metadata = await refreshClaudeRuntimeMetadata({
1865
- cwd: workingDir,
1866
- activeQuery: activeSessionQuery,
1867
- }).catch((error) => {
1868
- console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
1869
- return runtimeMetadata;
1870
- });
1871
- await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
1872
- console.error('[canon-host] Failed to publish agent runtime:', error);
1873
- });
1874
1918
  await publishHostSessionSnapshots({
1875
- conversationIds: Array.from(knownConversationIds),
1919
+ conversationIds,
1876
1920
  agentId,
1877
1921
  clientType: 'claude-code',
1878
1922
  runtime: runtimeDescriptor,
@@ -1887,6 +1931,7 @@ export async function main() {
1887
1931
  ...(session.state.model ? { model: session.state.model } : {}),
1888
1932
  ...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
1889
1933
  ...(session.state.effort ? { effort: session.state.effort } : {}),
1934
+ ...(session.state.ultracode ? { runtimeControlValues: { ultracode: session.state.ultracode } } : {}),
1890
1935
  ...(workspaceId ? { workspaceId } : {}),
1891
1936
  executionMode: session.environment.mode,
1892
1937
  executionBranch: session.environment.branch ?? null,
@@ -1896,6 +1941,31 @@ export async function main() {
1896
1941
  }).catch((error) => {
1897
1942
  console.error('[canon-host] Failed to publish session snapshots:', error);
1898
1943
  });
1944
+ }
1945
+ const publishRuntimeHeartbeat = async () => {
1946
+ heartbeatLocalRuntimeEntry(runtimeId, {
1947
+ agentId,
1948
+ agentName: profileAgentName,
1949
+ cwd: workingDir,
1950
+ baseCwd: workingDir,
1951
+ });
1952
+ if (!streamConnected)
1953
+ return;
1954
+ await refreshKnownConversationIds().catch((error) => {
1955
+ console.error('[canon-host] Failed to refresh known conversations:', error);
1956
+ });
1957
+ const activeSessionQuery = sessions.values().next().value?.query ?? null;
1958
+ const metadata = await refreshClaudeRuntimeMetadata({
1959
+ cwd: workingDir,
1960
+ activeQuery: activeSessionQuery,
1961
+ }).catch((error) => {
1962
+ console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
1963
+ return runtimeMetadata;
1964
+ });
1965
+ await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
1966
+ console.error('[canon-host] Failed to publish agent runtime:', error);
1967
+ });
1968
+ await publishSessionSnapshots(Array.from(knownConversationIds));
1899
1969
  // Rebuild so the descriptor picks up native slash commands from the
1900
1970
  // metadata refresh above — the initial descriptor is built before the
1901
1971
  // command inventory has loaded.
@@ -2293,6 +2363,7 @@ export async function main() {
2293
2363
  model: config?.model ?? runtimeModels[0]?.value,
2294
2364
  permissionMode: config?.permissionMode ?? 'default',
2295
2365
  effort: config?.effort,
2366
+ ultracode: config?.ultracode,
2296
2367
  availableModels: runtimeModels,
2297
2368
  availableExecutionModes: hostAvailableExecutionModes,
2298
2369
  runtimePresentation,
@@ -2300,6 +2371,7 @@ export async function main() {
2300
2371
  canRequestApproval: () => canRequestCanonApproval(conversationId),
2301
2372
  runtimeId,
2302
2373
  ownerId,
2374
+ publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
2303
2375
  }, resumeId);
2304
2376
  sessions.set(conversationId, session);
2305
2377
  await controlPoller.baseline([conversationId]);
@@ -2609,51 +2681,26 @@ export async function main() {
2609
2681
  return { consume: false };
2610
2682
  const ctrl = event.control;
2611
2683
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
2612
- if (ctrl.model && ctrl.model !== session.state.model) {
2613
- try {
2614
- await session.query.setModel(ctrl.model);
2615
- session.state.model = ctrl.model;
2616
- session.clearRuntimeControlError('model');
2617
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Model → ${ctrl.model}`);
2618
- }
2619
- catch (err) {
2620
- session.setRuntimeControlError('model', ctrl.model, err);
2621
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
2622
- }
2623
- }
2624
- const nextPermissionMode = parseClaudePermissionMode(ctrl.permissionMode);
2625
- if (ctrl.permissionMode && !nextPermissionMode) {
2626
- session.setRuntimeControlError('permissionMode', ctrl.permissionMode, new Error(`Unsupported Claude permission mode: ${ctrl.permissionMode}`));
2627
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Unsupported mode: ${ctrl.permissionMode}`);
2628
- }
2629
- else if (nextPermissionMode && nextPermissionMode !== session.state.permissionMode) {
2630
- try {
2631
- await session.query.setPermissionMode(nextPermissionMode);
2632
- session.state.permissionMode = nextPermissionMode;
2633
- session.clearRuntimeControlError('permissionMode');
2634
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${nextPermissionMode}`);
2635
- }
2636
- catch (err) {
2637
- session.setRuntimeControlError('permissionMode', nextPermissionMode, err);
2638
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
2639
- }
2640
- }
2641
- const nextEffort = normalizeClaudeEffortLevel(ctrl.effort);
2642
- if (nextEffort && nextEffort !== session.state.effort) {
2643
- try {
2644
- await applyClaudeEffortLevel(session.query, nextEffort);
2645
- session.state.effort = nextEffort;
2646
- session.clearRuntimeControlError('effort');
2647
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Effort → ${nextEffort}`);
2648
- }
2649
- catch (err) {
2650
- session.setRuntimeControlError('effort', nextEffort, err);
2651
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err);
2652
- }
2653
- }
2654
- else if (ctrl.effort && !nextEffort) {
2655
- session.setRuntimeControlError('effort', String(ctrl.effort), new Error('Unsupported Claude thinking level.'));
2656
- }
2684
+ await applyClaudeSessionControl({
2685
+ model: ctrl.model,
2686
+ permissionMode: ctrl.permissionMode,
2687
+ effort: ctrl.effort,
2688
+ ultracode: ctrl.runtimeControlValues?.ultracode,
2689
+ }, {
2690
+ state: session.state,
2691
+ parsePermissionMode: parseClaudePermissionMode,
2692
+ normalizeEffort: normalizeClaudeEffortLevel,
2693
+ parseUltracode: parseClaudeUltracodeMode,
2694
+ setModel: (model) => session.query.setModel(model),
2695
+ setPermissionMode: (mode) => session.query.setPermissionMode(mode),
2696
+ applyEffort: (level) => applyClaudeEffortLevel(session.query, level),
2697
+ applyUltracode: (enabled) => applyClaudeUltracode(session.query, enabled),
2698
+ setRuntimeControlError: (controlId, value, error) => session.setRuntimeControlError(controlId, value, error),
2699
+ clearRuntimeControlError: (controlId) => session.clearRuntimeControlError(controlId),
2700
+ // Publish the app-subscribed snapshot immediately so the control-pending
2701
+ // gate clears now instead of at the next 30s heartbeat (lag fix).
2702
+ publishSnapshot: () => { void publishSessionSnapshots([convoId]); },
2703
+ });
2657
2704
  session.writeState();
2658
2705
  }
2659
2706
  // Interrupt/stop-clear signal. Throwing leaves the node in place
@@ -1,4 +1,4 @@
1
- import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
1
+ import type { PermissionMode, 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';
@@ -66,3 +66,44 @@ export declare function mergeClaudeModelOptions(input: {
66
66
  selectedModel?: string | null;
67
67
  }): ModelOption[];
68
68
  export declare function formatClaudeControlError(error: unknown): string;
69
+ /** Requested session-control values from the control plane (raw, pre-validation). */
70
+ export interface ClaudeSessionControlInput {
71
+ model?: string;
72
+ permissionMode?: string;
73
+ effort?: string;
74
+ ultracode?: string;
75
+ }
76
+ /** Mutable mirror of the live session's control state — mutated in place on success. */
77
+ export interface ClaudeSessionControlState {
78
+ model?: string;
79
+ permissionMode?: string;
80
+ effort?: string;
81
+ ultracode?: string;
82
+ }
83
+ /**
84
+ * Injected validators + side effects so the apply logic stays host-agnostic and
85
+ * unit-testable. The host wires these to the live SDK query and its runtime
86
+ * control-error bookkeeping; tests inject fakes.
87
+ */
88
+ export interface ClaudeSessionControlDeps {
89
+ state: ClaudeSessionControlState;
90
+ parsePermissionMode: (value: unknown) => PermissionMode | null;
91
+ normalizeEffort: (value: unknown) => string | undefined;
92
+ parseUltracode: (value: unknown) => string | undefined;
93
+ setModel: (model: string) => Promise<void>;
94
+ setPermissionMode: (mode: PermissionMode) => Promise<void>;
95
+ applyEffort: (level: string) => Promise<void>;
96
+ applyUltracode: (enabled: boolean) => Promise<void>;
97
+ setRuntimeControlError: (controlId: string, value: string, error: unknown) => void;
98
+ clearRuntimeControlError: (controlId: string) => void;
99
+ publishSnapshot: () => void;
100
+ }
101
+ /**
102
+ * Apply a session-control request to the live Claude session. Mirrors the host's
103
+ * control semantics (validation, only-if-different checks, error recording,
104
+ * clear-on-success). Whenever a live op is invoked — on success OR caught
105
+ * failure — publishSnapshot() is called exactly once so the app-subscribed
106
+ * /agent-session snapshot converges immediately instead of waiting for the next
107
+ * heartbeat. If nothing changed, publishSnapshot() is not called.
108
+ */
109
+ export declare function applyClaudeSessionControl(control: ClaudeSessionControlInput, deps: ClaudeSessionControlDeps): Promise<void>;
@@ -82,3 +82,74 @@ export function formatClaudeControlError(error) {
82
82
  }
83
83
  return 'Claude Code did not apply that control.';
84
84
  }
85
+ /**
86
+ * Apply a session-control request to the live Claude session. Mirrors the host's
87
+ * control semantics (validation, only-if-different checks, error recording,
88
+ * clear-on-success). Whenever a live op is invoked — on success OR caught
89
+ * failure — publishSnapshot() is called exactly once so the app-subscribed
90
+ * /agent-session snapshot converges immediately instead of waiting for the next
91
+ * heartbeat. If nothing changed, publishSnapshot() is not called.
92
+ */
93
+ export async function applyClaudeSessionControl(control, deps) {
94
+ const { state } = deps;
95
+ let liveOpInvoked = false;
96
+ if (control.model && control.model !== state.model) {
97
+ liveOpInvoked = true;
98
+ try {
99
+ await deps.setModel(control.model);
100
+ state.model = control.model;
101
+ deps.clearRuntimeControlError('model');
102
+ }
103
+ catch (err) {
104
+ deps.setRuntimeControlError('model', control.model, err);
105
+ }
106
+ }
107
+ const nextPermissionMode = deps.parsePermissionMode(control.permissionMode);
108
+ if (control.permissionMode && !nextPermissionMode) {
109
+ deps.setRuntimeControlError('permissionMode', control.permissionMode, new Error(`Unsupported Claude permission mode: ${control.permissionMode}`));
110
+ }
111
+ else if (nextPermissionMode && nextPermissionMode !== state.permissionMode) {
112
+ liveOpInvoked = true;
113
+ try {
114
+ await deps.setPermissionMode(nextPermissionMode);
115
+ state.permissionMode = nextPermissionMode;
116
+ deps.clearRuntimeControlError('permissionMode');
117
+ }
118
+ catch (err) {
119
+ deps.setRuntimeControlError('permissionMode', nextPermissionMode, err);
120
+ }
121
+ }
122
+ const nextEffort = deps.normalizeEffort(control.effort);
123
+ if (nextEffort && nextEffort !== state.effort) {
124
+ liveOpInvoked = true;
125
+ try {
126
+ await deps.applyEffort(nextEffort);
127
+ state.effort = nextEffort;
128
+ deps.clearRuntimeControlError('effort');
129
+ }
130
+ catch (err) {
131
+ deps.setRuntimeControlError('effort', nextEffort, err);
132
+ }
133
+ }
134
+ else if (control.effort && !nextEffort) {
135
+ deps.setRuntimeControlError('effort', String(control.effort), new Error('Unsupported Claude thinking level.'));
136
+ }
137
+ const nextUltracode = deps.parseUltracode(control.ultracode);
138
+ if (control.ultracode && !nextUltracode) {
139
+ deps.setRuntimeControlError('ultracode', String(control.ultracode), new Error('Unsupported Claude ultracode mode.'));
140
+ }
141
+ else if (nextUltracode && nextUltracode !== state.ultracode) {
142
+ liveOpInvoked = true;
143
+ try {
144
+ await deps.applyUltracode(nextUltracode === 'on');
145
+ state.ultracode = nextUltracode;
146
+ deps.clearRuntimeControlError('ultracode');
147
+ }
148
+ catch (err) {
149
+ deps.setRuntimeControlError('ultracode', nextUltracode, err);
150
+ }
151
+ }
152
+ if (liveOpInvoked) {
153
+ deps.publishSnapshot();
154
+ }
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.23.0",
3
+ "version": "0.24.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,10 +30,10 @@
30
30
  "test": "vitest run"
31
31
  },
32
32
  "dependencies": {
33
- "@anthropic-ai/claude-agent-sdk": "^0.2.141",
33
+ "@anthropic-ai/claude-agent-sdk": "^0.3.200",
34
34
  "@canonmsg/agent-sdk": "^5.0.0",
35
35
  "@canonmsg/coding-agent-host": "^0.2.2",
36
- "@canonmsg/core": "^4.0.0",
36
+ "@canonmsg/core": "^4.1.0",
37
37
  "@modelcontextprotocol/sdk": "^1.29.0"
38
38
  },
39
39
  "engines": {