@canonmsg/claude-code-plugin 0.23.0 → 0.25.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
@@ -28,12 +28,12 @@ import { readFile } from 'node:fs/promises';
28
28
  import { query, } from '@anthropic-ai/claude-agent-sdk';
29
29
  import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
30
30
  import { captureTurnArtifactSnapshot, collectTurnArtifacts, } from '@canonmsg/coding-agent-host';
31
- import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPlanApprovalRequest, resolveQuestionAllowOther, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, sendMessageWithRetry, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
31
+ import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, resolveQuestionAllowOther, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, sendMessageWithRetry, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
32
32
  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) {
@@ -729,7 +769,6 @@ async function buildCanonUserContent(input) {
729
769
  function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
730
770
  const { cwd } = environment;
731
771
  const cardResponseUserId = config.ownerId ?? undefined;
732
- const cardResponseUser = cardResponseUserId ? { responseUserId: cardResponseUserId } : undefined;
733
772
  // Per-session input stream with message queue
734
773
  let resolveInput = null;
735
774
  let inputDone = false;
@@ -804,30 +843,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
804
843
  };
805
844
  });
806
845
  }
807
- async function waitForRuntimeInputResponse(input) {
808
- session.lastActivity = Date.now();
809
- while (Date.now() < input.expiresAt) {
810
- const response = await client.consumeRuntimeInputResponse({
811
- conversationId,
812
- inputId: input.inputId,
813
- }).catch(() => null);
814
- if (response?.status === 'submitted') {
815
- return { status: 'submitted', answers: response.answers, value: response.value };
816
- }
817
- if (response?.status === 'cancelled' || response?.status === 'timeout') {
818
- return { status: response.status };
819
- }
820
- await sleep(1_000);
821
- }
822
- const response = await client.consumeRuntimeInputResponse({
823
- conversationId,
824
- inputId: input.inputId,
825
- }).catch(() => null);
826
- if (response?.status === 'submitted') {
827
- return { status: 'submitted', answers: response.answers, value: response.value };
828
- }
829
- return { status: 'timeout' };
830
- }
831
846
  function buildArtifactMediaMetadata() {
832
847
  return {
833
848
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
@@ -863,18 +878,19 @@ function createSession(conversationId, environment, agentId, client, typingSigna
863
878
  const questionId = randomUUID();
864
879
  const questions = mapClaudeQuestions(input.questions);
865
880
  const expiresAt = Date.now() + REPLY_TIMEOUT_MS;
866
- await client.createRuntimeInputRequest({
867
- conversationId,
868
- inputId: questionId,
881
+ // Create + poll fold onto the shared spine. `responderPolicy: 'infer'`
882
+ // keeps the claude host's responder rule byte-identical (explicit
883
+ // `cardResponseUserId` wins, else omit — never the manager's ownerId
884
+ // fallback), and `native` carries the claude-specific descriptor.
885
+ const reply = await config.runtimeInputManager.request('input', conversationId, {
869
886
  kind: 'clarify',
870
- expiresAt,
871
887
  title: 'Claude needs input',
872
888
  prompt: questions?.length
873
889
  ? 'Claude needs your input to continue.'
874
890
  : 'Claude needs input to continue.',
875
891
  ...(questions ? { questions } : {}),
876
892
  sensitive: Boolean(questions?.some((question) => question.isSecret)),
877
- ...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
893
+ responseUserId: cardResponseUserId,
878
894
  native: {
879
895
  runtime: 'claude-code',
880
896
  method: 'AskUserQuestion',
@@ -882,9 +898,15 @@ function createSession(conversationId, environment, agentId, client, typingSigna
882
898
  turnId: session.currentTurnId ?? undefined,
883
899
  },
884
900
  turnId: session.currentTurnId ?? undefined,
901
+ }, {
902
+ requestId: questionId,
903
+ expiresAt,
904
+ responderPolicy: 'infer',
905
+ onCreated: () => {
906
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
907
+ session.lastActivity = Date.now();
908
+ },
885
909
  });
886
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
887
- const reply = await waitForRuntimeInputResponse({ inputId: questionId, expiresAt });
888
910
  if (reply.status !== 'submitted') {
889
911
  return { behavior: 'deny', message: 'No reply received (timed out)' };
890
912
  }
@@ -930,22 +952,19 @@ function createSession(conversationId, environment, agentId, client, typingSigna
930
952
  const planBody = typeof input.plan === 'string' && input.plan.trim()
931
953
  ? input.plan.trim()
932
954
  : undefined;
933
- const planApproval = buildPlanApprovalRequest(planId, 'Plan ready for review.', {
955
+ // Route plan-CREATE through the unified spine (`/runtime-plan/request`):
956
+ // seeds the server-owned pending node/attention/state and authors the
957
+ // `plan_approval` card. Resolution is UNCHANGED — the gate still blocks
958
+ // on the server-authored `plan_approval_reply` below.
959
+ await client.createRuntimePlanRequest({
960
+ conversationId,
961
+ planId,
962
+ title: 'Plan ready for review.',
934
963
  summary: 'Claude has prepared a plan and is asking permission to continue.',
935
964
  ...(planBody ? { body: planBody } : {}),
936
- ...(cardResponseUser ?? {}),
965
+ ...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
937
966
  ...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
938
- });
939
- await sendMessageWithRetry(client, conversationId, planApproval.text, {
940
- messageId: `claude-plan-${planId}`,
941
- ...(session.activeSelfContextId
942
- ? { selfContextId: session.activeSelfContextId }
943
- : {}),
944
- metadata: {
945
- ...planApproval.metadata,
946
- turnSemantics: 'control',
947
- replyBehavior: 'suppress_auto_reply',
948
- },
967
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
949
968
  });
950
969
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
951
970
  const reply = await waitForReply('plan_approval_reply', planId);
@@ -988,6 +1007,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
988
1007
  if (session.state.permissionMode === 'plan') {
989
1008
  session.state.permissionMode = 'default';
990
1009
  writeState();
1010
+ // Converge the app immediately on the SDK-driven plan-mode exit
1011
+ // instead of waiting for the next 30s heartbeat (lag fix).
1012
+ config.publishSessionSnapshots?.([conversationId]);
991
1013
  }
992
1014
  return { behavior: 'allow' };
993
1015
  }
@@ -1056,6 +1078,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1056
1078
  model: undefined,
1057
1079
  permissionMode: config.permissionMode,
1058
1080
  effort: undefined,
1081
+ ultracode: undefined,
1059
1082
  state: 'idle',
1060
1083
  },
1061
1084
  availableModels: config.availableModels,
@@ -1324,10 +1347,17 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1324
1347
  else if (config.effort !== undefined) {
1325
1348
  controlState.effort = { value: config.effort, source: 'requested' };
1326
1349
  }
1350
+ if (session.state.ultracode !== undefined) {
1351
+ controlState.ultracode = { value: session.state.ultracode, source: 'applied', appliedAt };
1352
+ }
1353
+ else if (config.ultracode !== undefined) {
1354
+ controlState.ultracode = { value: config.ultracode, source: 'requested' };
1355
+ }
1327
1356
  runtimeState.writeSessionState(conversationId, {
1328
1357
  model: session.state.model,
1329
1358
  permissionMode: session.state.permissionMode,
1330
1359
  effort: session.state.effort,
1360
+ ...(session.state.ultracode ? { runtimeControlValues: { ultracode: session.state.ultracode } } : {}),
1331
1361
  controlState,
1332
1362
  runtimeControlErrors,
1333
1363
  contextUsage: session.state.contextUsage,
@@ -1666,6 +1696,15 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1666
1696
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial effort:`, err);
1667
1697
  }
1668
1698
  }
1699
+ if (config.ultracode) {
1700
+ try {
1701
+ await applyClaudeUltracode(q, config.ultracode === 'on');
1702
+ session.state.ultracode = config.ultracode;
1703
+ }
1704
+ catch (err) {
1705
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial ultracode:`, err);
1706
+ }
1707
+ }
1669
1708
  if (!resumeSessionId) {
1670
1709
  // New session — send seed message to activate streaming input mode
1671
1710
  sendInput(createClaudeInputEnvelope({
@@ -1848,31 +1887,15 @@ export async function main() {
1848
1887
  const promptedGroupContextConversationIds = new Set();
1849
1888
  const pendingMembershipChanges = new Map();
1850
1889
  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)
1890
+ // Publish the app-subscribed /agent-session snapshot for the given
1891
+ // conversations. Extracted from the heartbeat so control handlers can converge
1892
+ // the app immediately instead of waiting for the next 30s heartbeat
1893
+ // (session-control lag fix). Hoisted so it is reachable from earlier code.
1894
+ async function publishSessionSnapshots(conversationIds) {
1895
+ if (!streamConnected || conversationIds.length === 0)
1859
1896
  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
1897
  await publishHostSessionSnapshots({
1875
- conversationIds: Array.from(knownConversationIds),
1898
+ conversationIds,
1876
1899
  agentId,
1877
1900
  clientType: 'claude-code',
1878
1901
  runtime: runtimeDescriptor,
@@ -1887,6 +1910,7 @@ export async function main() {
1887
1910
  ...(session.state.model ? { model: session.state.model } : {}),
1888
1911
  ...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
1889
1912
  ...(session.state.effort ? { effort: session.state.effort } : {}),
1913
+ ...(session.state.ultracode ? { runtimeControlValues: { ultracode: session.state.ultracode } } : {}),
1890
1914
  ...(workspaceId ? { workspaceId } : {}),
1891
1915
  executionMode: session.environment.mode,
1892
1916
  executionBranch: session.environment.branch ?? null,
@@ -1896,6 +1920,31 @@ export async function main() {
1896
1920
  }).catch((error) => {
1897
1921
  console.error('[canon-host] Failed to publish session snapshots:', error);
1898
1922
  });
1923
+ }
1924
+ const publishRuntimeHeartbeat = async () => {
1925
+ heartbeatLocalRuntimeEntry(runtimeId, {
1926
+ agentId,
1927
+ agentName: profileAgentName,
1928
+ cwd: workingDir,
1929
+ baseCwd: workingDir,
1930
+ });
1931
+ if (!streamConnected)
1932
+ return;
1933
+ await refreshKnownConversationIds().catch((error) => {
1934
+ console.error('[canon-host] Failed to refresh known conversations:', error);
1935
+ });
1936
+ const activeSessionQuery = sessions.values().next().value?.query ?? null;
1937
+ const metadata = await refreshClaudeRuntimeMetadata({
1938
+ cwd: workingDir,
1939
+ activeQuery: activeSessionQuery,
1940
+ }).catch((error) => {
1941
+ console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
1942
+ return runtimeMetadata;
1943
+ });
1944
+ await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
1945
+ console.error('[canon-host] Failed to publish agent runtime:', error);
1946
+ });
1947
+ await publishSessionSnapshots(Array.from(knownConversationIds));
1899
1948
  // Rebuild so the descriptor picks up native slash commands from the
1900
1949
  // metadata refresh above — the initial descriptor is built before the
1901
1950
  // command inventory has loaded.
@@ -1992,6 +2041,10 @@ export async function main() {
1992
2041
  // ── Session manager ──
1993
2042
  const sessions = new Map();
1994
2043
  const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
2044
+ // Interactive runtime input (AskUserQuestion) works with or without an owner,
2045
+ // so its poll engine is always present. Create + poll fold onto the built-in
2046
+ // input descriptor via `request()` (see the AskUserQuestion canUseTool branch).
2047
+ const runtimeInputManager = new RuntimeRequestManager(client, { agentId, ownerId: ownerId ?? '' });
1995
2048
  const pendingSessionCreations = new Map();
1996
2049
  // Shared /control channel poller (claude host profile): session + signal
1997
2050
  // keys, sequential conversations, active/idle cadence + jitter sampled at
@@ -2293,13 +2346,16 @@ export async function main() {
2293
2346
  model: config?.model ?? runtimeModels[0]?.value,
2294
2347
  permissionMode: config?.permissionMode ?? 'default',
2295
2348
  effort: config?.effort,
2349
+ ultracode: config?.ultracode,
2296
2350
  availableModels: runtimeModels,
2297
2351
  availableExecutionModes: hostAvailableExecutionModes,
2298
2352
  runtimePresentation,
2299
2353
  approvalManager,
2354
+ runtimeInputManager,
2300
2355
  canRequestApproval: () => canRequestCanonApproval(conversationId),
2301
2356
  runtimeId,
2302
2357
  ownerId,
2358
+ publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
2303
2359
  }, resumeId);
2304
2360
  sessions.set(conversationId, session);
2305
2361
  await controlPoller.baseline([conversationId]);
@@ -2609,51 +2665,26 @@ export async function main() {
2609
2665
  return { consume: false };
2610
2666
  const ctrl = event.control;
2611
2667
  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
- }
2668
+ await applyClaudeSessionControl({
2669
+ model: ctrl.model,
2670
+ permissionMode: ctrl.permissionMode,
2671
+ effort: ctrl.effort,
2672
+ ultracode: ctrl.runtimeControlValues?.ultracode,
2673
+ }, {
2674
+ state: session.state,
2675
+ parsePermissionMode: parseClaudePermissionMode,
2676
+ normalizeEffort: normalizeClaudeEffortLevel,
2677
+ parseUltracode: parseClaudeUltracodeMode,
2678
+ setModel: (model) => session.query.setModel(model),
2679
+ setPermissionMode: (mode) => session.query.setPermissionMode(mode),
2680
+ applyEffort: (level) => applyClaudeEffortLevel(session.query, level),
2681
+ applyUltracode: (enabled) => applyClaudeUltracode(session.query, enabled),
2682
+ setRuntimeControlError: (controlId, value, error) => session.setRuntimeControlError(controlId, value, error),
2683
+ clearRuntimeControlError: (controlId) => session.clearRuntimeControlError(controlId),
2684
+ // Publish the app-subscribed snapshot immediately so the control-pending
2685
+ // gate clears now instead of at the next 30s heartbeat (lag fix).
2686
+ publishSnapshot: () => { void publishSessionSnapshots([convoId]); },
2687
+ });
2657
2688
  session.writeState();
2658
2689
  }
2659
2690
  // Interrupt/stop-clear signal. Throwing leaves the node in place
@@ -2731,6 +2762,7 @@ export async function main() {
2731
2762
  clearInterval(idleCheck);
2732
2763
  stream.stop();
2733
2764
  approvalManager?.dispose();
2765
+ runtimeInputManager.dispose();
2734
2766
  await runtimeState.clearAgentRuntime().catch(() => { });
2735
2767
  for (const convoId of [...sessions.keys()]) {
2736
2768
  closeSession(convoId);
@@ -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.25.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",
34
- "@canonmsg/agent-sdk": "^5.0.0",
33
+ "@anthropic-ai/claude-agent-sdk": "^0.3.200",
34
+ "@canonmsg/agent-sdk": "^5.1.0",
35
35
  "@canonmsg/coding-agent-host": "^0.2.2",
36
- "@canonmsg/core": "^4.0.0",
36
+ "@canonmsg/core": "^4.2.0",
37
37
  "@modelcontextprotocol/sdk": "^1.29.0"
38
38
  },
39
39
  "engines": {