@canonmsg/claude-code-plugin 0.33.0 → 0.34.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "Canon",
3
3
  "description": "Connect Claude Code to Canon — messaging where AI agents are first-class citizens",
4
- "version": "0.33.0",
4
+ "version": "0.34.1",
5
5
  "channels": [
6
6
  {
7
7
  "server": "canon-channel",
@@ -0,0 +1,15 @@
1
+ import type { AgentContext } from '@canonmsg/core';
2
+ /** Coordinates first identity discovery with the MCP client's cached tool list. */
3
+ export declare class AgentContextState {
4
+ private readonly onCommunicationAvailabilityChanged;
5
+ private resolveReady;
6
+ private settled;
7
+ private contextValue;
8
+ readonly ready: Promise<void>;
9
+ constructor(onCommunicationAvailabilityChanged: () => void);
10
+ get current(): AgentContext | null;
11
+ set(context: AgentContext): void;
12
+ /** Resolve tools/list even when startup cannot obtain a trusted identity. */
13
+ settleUnavailable(): void;
14
+ private settle;
15
+ }
@@ -0,0 +1,35 @@
1
+ import { isProactiveCommunicationEnabled } from './communication-policy.js';
2
+ /** Coordinates first identity discovery with the MCP client's cached tool list. */
3
+ export class AgentContextState {
4
+ onCommunicationAvailabilityChanged;
5
+ resolveReady;
6
+ settled = false;
7
+ contextValue = null;
8
+ ready = new Promise((resolve) => {
9
+ this.resolveReady = resolve;
10
+ });
11
+ constructor(onCommunicationAvailabilityChanged) {
12
+ this.onCommunicationAvailabilityChanged = onCommunicationAvailabilityChanged;
13
+ }
14
+ get current() {
15
+ return this.contextValue;
16
+ }
17
+ set(context) {
18
+ const wasEnabled = isProactiveCommunicationEnabled(this.contextValue?.outboundPolicy);
19
+ this.contextValue = context;
20
+ this.settle();
21
+ const isEnabled = isProactiveCommunicationEnabled(context.outboundPolicy);
22
+ if (wasEnabled !== isEnabled)
23
+ this.onCommunicationAvailabilityChanged();
24
+ }
25
+ /** Resolve tools/list even when startup cannot obtain a trusted identity. */
26
+ settleUnavailable() {
27
+ this.settle();
28
+ }
29
+ settle() {
30
+ if (this.settled)
31
+ return;
32
+ this.settled = true;
33
+ this.resolveReady();
34
+ }
35
+ }
@@ -0,0 +1,2 @@
1
+ /** Fail closed until trusted `/agents/me` context enables proactive communication. */
2
+ export declare function isProactiveCommunicationEnabled(policy: unknown): boolean;
@@ -0,0 +1,4 @@
1
+ /** Fail closed until trusted `/agents/me` context enables proactive communication. */
2
+ export function isProactiveCommunicationEnabled(policy) {
3
+ return policy === 'open' || policy === 'approval-required';
4
+ }
package/dist/host.d.ts CHANGED
@@ -25,9 +25,7 @@ export declare function buildClaudePlanAllowResult(input: Record<string, unknown
25
25
  * through. Canon-injected aliases keep precedence over native names.
26
26
  */
27
27
  export declare function buildNativeSlashCommandDescriptors(native: SDKControlReloadPluginsResponse['commands'], reservedAliases: ReadonlySet<string>): CanonRuntimeCommandDescriptor[];
28
- export declare function resolveSessionExecutionMode(config: {
29
- executionMode?: ExecutionEnvironmentMode;
30
- } | null | undefined, defaultExecutionMode?: ExecutionEnvironmentMode): ExecutionEnvironmentMode;
28
+ export declare function resolveSessionExecutionMode(defaultExecutionMode?: ExecutionEnvironmentMode): ExecutionEnvironmentMode;
31
29
  export declare function createClaudeRecoveryCheckpointTracker(persist: (messageId: string) => boolean): RecoveryCheckpointTracker;
32
30
  export declare const NO_REPLY_TOOL_NAME: string;
33
31
  export declare function buildCanonPrompt(input: {
package/dist/host.js CHANGED
@@ -29,13 +29,14 @@ import { query, } from '@anthropic-ai/claude-agent-sdk';
29
29
  import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
30
30
  import { captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, collectMissedInboundMessages, createRecoveryCheckpointTracker, createReconnectRecoveryCoordinator, STARTUP_RECOVERY_PAGE_SIZE, } from '@canonmsg/coding-agent-host';
31
31
  import { buildCanonUserContent, renderClaudeInboundContent, withClaudeReplyContextMediaReferences, } from './canon-user-content.js';
32
- import { 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, 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, parseTurnVerbosityConfig, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, sendMessageWithRetry, sendMessageWithRetryChunked, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
32
+ import { 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, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, parseTurnVerbosityConfig, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, sendMessageWithRetry, sendMessageWithRetryChunked, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, resolveLocalRuntimeSessionState, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
33
33
  import { runCli } from '@canonmsg/core';
34
34
  import { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer } from '@canonmsg/agent-tools';
35
35
  import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
36
36
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
37
- import { applyClaudeSessionControl, boundClaudeFinalMetadata, buildClaudeFinalChunkingOptions, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudePendingFinalDelivery, buildClaudeTurnFailureNotice, buildTruncatedFinalText, buildUndeliverableFinalNotice, canDrainClaudeQueuedInput, beginClaudeAssistantResponse, claimClaudeTextSegmentId, claudeFinalWillChunk, classifyFinalDeliveryFailure, claudeInputOwnsTurnSlot, createClaudeTurnActivityState, decideClaudeControlSignalAction, decideClaudeInboundDispatch, dispatchClaudeInput, endClaudeAssistantResponse, isClaudeTurnSlotReserved, readClaudeFinalDeliveryResume, releaseClaudeTurnSlot, runClaudeExhaustedFinalDelivery, shouldApplyClaudeEchoedSessionState, shouldReleaseClaudeTurnSlot, isClaudeMainTurnMessage, openClaudeTurn, planClaudeAssistantTrail, planClaudeStreamingWrite, planClaudeToolCallStart, planClaudeToolProgress, planClaudeToolResults, claudeFinalTurnTrail, publishedClaudeTurnState, rememberDispatchedClaudeInput, resetClaudeTurnActivityState, shouldOpenClaudeTurnOnRunning, shouldStopTypingDotsOnStreamedText, isOpenClaudeTurnState, takeClaudeResultOwner, takeClaudeToolBlockIdByIndex, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeTurnFinal, describeUndeliveredClaudeFinal, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, shouldRouteClaudeTurnArtifacts, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
37
+ import { boundClaudeFinalMetadata, buildClaudeFinalChunkingOptions, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudePendingFinalDelivery, buildClaudeTurnFailureNotice, buildTruncatedFinalText, buildUndeliverableFinalNotice, canDrainClaudeQueuedInput, beginClaudeAssistantResponse, claimClaudeTextSegmentId, claudeFinalWillChunk, classifyFinalDeliveryFailure, claudeInputOwnsTurnSlot, createClaudeTurnActivityState, decideClaudeControlSignalAction, decideClaudeInboundDispatch, dispatchClaudeInput, endClaudeAssistantResponse, isClaudeTurnSlotReserved, readClaudeFinalDeliveryResume, releaseClaudeTurnSlot, runClaudeExhaustedFinalDelivery, shouldApplyClaudeEchoedSessionState, shouldReleaseClaudeTurnSlot, isClaudeMainTurnMessage, openClaudeTurn, planClaudeAssistantTrail, planClaudeStreamingWrite, planClaudeToolCallStart, planClaudeToolProgress, planClaudeToolResults, claudeFinalTurnTrail, publishedClaudeTurnState, rememberDispatchedClaudeInput, resetClaudeTurnActivityState, shouldOpenClaudeTurnOnRunning, shouldStopTypingDotsOnStreamedText, isOpenClaudeTurnState, takeClaudeResultOwner, takeClaudeToolBlockIdByIndex, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeTurnFinal, describeUndeliveredClaudeFinal, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, shouldRouteClaudeTurnArtifacts, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
38
38
  import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } from './user-dialog.js';
39
+ import { isProactiveCommunicationEnabled } from './communication-policy.js';
39
40
  function parseRuntimeVisibilityPreset(value) {
40
41
  return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
41
42
  }
@@ -88,12 +89,10 @@ let workspaceRoots = [];
88
89
  let workspaceRootMetadata = [];
89
90
  let runtimeModels = [];
90
91
  const CLAUDE_METADATA_TTL_MS = 5 * 60 * 1000;
92
+ const LOCAL_CONFIGURATION_REQUIRED_MESSAGE = 'This agent needs local runtime configuration before it can start this conversation. '
93
+ + 'Its operator must configure it and retry.';
91
94
  const allowedNonOwnerClaudeTools = parseAllowedNonOwnerClaudeTools(process.env.CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS);
92
- /**
93
- * Agent-developer setting, resolved once at startup. Deliberately NOT read from
94
- * `/session-config`: that path is the USER's per-conversation control plane,
95
- * and owner ruling 5 puts turn verbosity outside user control.
96
- */
95
+ /** Turn verbosity is local agent-developer configuration, resolved at startup. */
97
96
  let configuredTurnVerbosity = null;
98
97
  /**
99
98
  * `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
@@ -418,24 +417,8 @@ function toModelOptions(models) {
418
417
  async function publishAgentRuntime(agentId, runtime, rtdb) {
419
418
  await publishHostAgentRuntime(agentId, 'claude-code', runtime, rtdb);
420
419
  }
421
- async function loadSessionConfig(conversationId, agentId, rtdb) {
422
- const config = await loadHostSessionConfig({
423
- conversationId,
424
- agentId,
425
- rtdb,
426
- extraStringFields: ['permissionMode', 'effort'],
427
- });
428
- return {
429
- ...config,
430
- permissionMode: config?.permissionMode
431
- ? parseClaudePermissionMode(config.permissionMode) ?? undefined
432
- : undefined,
433
- effort: normalizeClaudeEffortLevel(config?.effort),
434
- ultracode: parseClaudeUltracodeMode(config?.runtimeControlValues?.ultracode),
435
- };
436
- }
437
- export function resolveSessionExecutionMode(config, defaultExecutionMode = 'worktree') {
438
- return config?.executionMode ?? defaultExecutionMode;
420
+ export function resolveSessionExecutionMode(defaultExecutionMode = 'worktree') {
421
+ return defaultExecutionMode;
439
422
  }
440
423
  function resolveConfiguredDefaultExecutionMode(value) {
441
424
  if (value == null || value === '')
@@ -444,10 +427,10 @@ function resolveConfiguredDefaultExecutionMode(value) {
444
427
  return value;
445
428
  throw new Error('--default-execution-mode must be worktree or locked');
446
429
  }
447
- function resolveWorkspaceCwd(config) {
448
- return resolveHostWorkspaceCwd({
430
+ function resolveWorkspaceCwd() {
431
+ return resolveConfiguredWorkspaceCwd({
449
432
  workspaceOptions,
450
- config,
433
+ workspaceId: workspaceOptions[0]?.id,
451
434
  defaultCwd: workingDir,
452
435
  });
453
436
  }
@@ -990,7 +973,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
990
973
  const turnKey = session.activeInput?.turnKey;
991
974
  if (turnKey)
992
975
  session.silencedTurnKeys.add(turnKey);
993
- }, { communication: config.communicationEnabled !== false }),
976
+ }, { communication: config.communicationEnabled === true }),
994
977
  },
995
978
  },
996
979
  ...(claudeCliPath ? { pathToClaudeCodeExecutable: claudeCliPath } : {}),
@@ -1456,6 +1439,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1456
1439
  deliveryIntent: session.lastAcceptedIntent,
1457
1440
  suppressAutoReply: input.suppressAutoReply,
1458
1441
  }),
1442
+ ...(input.turn.replyAuthority ? { replyAuthority: input.turn.replyAuthority } : {}),
1459
1443
  });
1460
1444
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
1461
1445
  + (degraded.length === input.finalText.length
@@ -1523,6 +1507,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1523
1507
  messageId: finalMessageId,
1524
1508
  ...(selfContextId ? { selfContextId } : {}),
1525
1509
  metadata,
1510
+ ...(deliverTurn.replyAuthority ? { replyAuthority: deliverTurn.replyAuthority } : {}),
1526
1511
  }, {}, buildClaudeFinalChunkingOptions(resume));
1527
1512
  markFinalTurnDelivered(deliverTurn);
1528
1513
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars`
@@ -1585,6 +1570,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1585
1570
  }
1586
1571
  function sendTurnArtifactFile(file) {
1587
1572
  return sendMediaFileMessage(client, conversationId, file.path, '', {
1573
+ ...(session.activeInput?.replyAuthority
1574
+ ? { replyAuthority: session.activeInput.replyAuthority }
1575
+ : {}),
1588
1576
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1589
1577
  metadata: buildArtifactMediaMetadata(),
1590
1578
  });
@@ -1663,51 +1651,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1663
1651
  }, FINAL_DELIVERY_RETRY_MS);
1664
1652
  }
1665
1653
  function writeState() {
1666
- const runtimeControlErrors = Object.keys(session.runtimeControlErrors).length > 0
1667
- ? session.runtimeControlErrors
1668
- : null;
1669
- const controlState = {};
1670
- const appliedAt = Date.now();
1671
- if (session.state.model !== undefined) {
1672
- controlState.model = { value: session.state.model, source: 'applied', appliedAt };
1673
- }
1674
- else if (config.model !== undefined) {
1675
- controlState.model = { value: config.model, source: 'requested' };
1676
- }
1677
- if (session.state.permissionMode !== undefined) {
1678
- controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
1679
- }
1680
- else if (config.permissionMode !== undefined) {
1681
- controlState.permissionMode = { value: config.permissionMode, source: 'requested' };
1682
- }
1683
- if (session.state.effort !== undefined) {
1684
- controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
1685
- }
1686
- else if (config.effort !== undefined) {
1687
- controlState.effort = { value: config.effort, source: 'requested' };
1688
- }
1689
- if (session.state.ultracode !== undefined) {
1690
- controlState.ultracode = { value: session.state.ultracode, source: 'applied', appliedAt };
1691
- }
1692
- else if (config.ultracode !== undefined) {
1693
- controlState.ultracode = { value: config.ultracode, source: 'requested' };
1694
- }
1695
1654
  runtimeState.writeSessionState(conversationId, {
1696
- model: session.state.model,
1697
- permissionMode: session.state.permissionMode,
1698
- effort: session.state.effort,
1699
- ...(session.state.ultracode ? { runtimeControlValues: { ultracode: session.state.ultracode } } : {}),
1700
- controlState,
1701
- runtimeControlErrors,
1702
1655
  contextUsage: session.state.contextUsage,
1703
- ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
1704
- cwd,
1705
- executionMode: session.environment.mode,
1706
- ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
1707
- ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
1708
- ...(resolveExecutionFallbackReason(session.environment)
1709
- ? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
1710
- : {}),
1711
1656
  hostMode: true,
1712
1657
  isActive: true,
1713
1658
  }).catch(() => { });
@@ -2484,13 +2429,13 @@ export async function main() {
2484
2429
  let agentId;
2485
2430
  let ownerId = null;
2486
2431
  let ownerName = null;
2487
- let communicationEnabled = true;
2432
+ let communicationEnabled = false;
2488
2433
  try {
2489
2434
  const ctx = await client.getAgentMe();
2490
2435
  agentId = ctx.agentId;
2491
2436
  ownerId = ctx.ownerId;
2492
2437
  ownerName = ctx.ownerName;
2493
- communicationEnabled = ctx.outboundPolicy !== 'closed';
2438
+ communicationEnabled = isProactiveCommunicationEnabled(ctx.outboundPolicy);
2494
2439
  console.error(`[canon-host] Connected as ${ctx.displayName || agentId}`);
2495
2440
  }
2496
2441
  catch {
@@ -2580,25 +2525,6 @@ export async function main() {
2580
2525
  agentId,
2581
2526
  rtdb,
2582
2527
  clientType: 'claude-code',
2583
- runtime: runtimeDescriptor,
2584
- workspaceOptions,
2585
- defaultCwd: workingDir,
2586
- extraSessionConfigFields: ['permissionMode', 'effort'],
2587
- liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
2588
- const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
2589
- return [
2590
- session.conversationId,
2591
- {
2592
- ...(session.state.model ? { model: session.state.model } : {}),
2593
- ...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
2594
- ...(session.state.effort ? { effort: session.state.effort } : {}),
2595
- ...(session.state.ultracode ? { runtimeControlValues: { ultracode: session.state.ultracode } } : {}),
2596
- ...(workspaceId ? { workspaceId } : {}),
2597
- executionMode: session.environment.mode,
2598
- executionBranch: session.environment.branch ?? null,
2599
- },
2600
- ];
2601
- })),
2602
2528
  }).catch((error) => {
2603
2529
  console.error('[canon-host] Failed to publish session snapshots:', error);
2604
2530
  });
@@ -2742,8 +2668,8 @@ export async function main() {
2742
2668
  // input descriptor via `request()` (see the AskUserQuestion canUseTool branch).
2743
2669
  const runtimeInputManager = new RuntimeRequestManager(client, { agentId, ownerId: ownerId ?? '' });
2744
2670
  const pendingSessionCreations = new Map();
2745
- // Shared /control channel poller (claude host profile): session + signal
2746
- // keys, sequential conversations, active/idle cadence + jitter sampled at
2671
+ // Shared /control channel poller (claude host profile): signal keys,
2672
+ // sequential conversations, active/idle cadence + jitter sampled at
2747
2673
  // cycle start, eager baselines at session creation, and nodes left in place
2748
2674
  // when a handler throws (consumeOnError defaults to false for both keys).
2749
2675
  const controlPoller = new ControlChannelPoller({
@@ -2765,7 +2691,6 @@ export async function main() {
2765
2691
  pollOnStart: true,
2766
2692
  conversationConcurrency: 'sequential',
2767
2693
  handlers: {
2768
- session: { handle: applyRuntimeSessionControl },
2769
2694
  signal: { handle: handleRuntimeControlSignal },
2770
2695
  },
2771
2696
  onError: ({ scope, key, conversationId, error }) => {
@@ -3000,22 +2925,47 @@ export async function main() {
3000
2925
  evictOldestIdle();
3001
2926
  }
3002
2927
  const creation = (async () => {
3003
- const config = await loadSessionConfig(conversationId, agentId, rtdb);
3004
- const sessionExecutionMode = resolveSessionExecutionMode(config, defaultExecutionMode);
3005
- const workspaceCwd = resolveWorkspaceCwd(config);
3006
- const environment = prepareConversationEnvironment({
2928
+ const sessionExecutionMode = resolveSessionExecutionMode(defaultExecutionMode);
2929
+ const workspaceCwd = resolveWorkspaceCwd();
2930
+ let environment = prepareConversationEnvironment({
3007
2931
  agentId,
3008
2932
  conversationId,
3009
2933
  workspaceCwd,
3010
2934
  allowWorktrees: sessionExecutionMode === 'worktree',
3011
2935
  });
3012
2936
  try {
3013
- const resumeKey = buildConversationEnvironmentKey(conversationId, environment.baseCwd);
3014
- const persisted = loadRuntimeSessionState(runtimeId, {
2937
+ const persistedMapping = resolveLocalRuntimeSessionState(runtimeId, {
3015
2938
  conversationId,
3016
2939
  baseCwd: environment.baseCwd,
3017
2940
  executionMode: environment.mode,
2941
+ resumeField: 'claudeSessionId',
2942
+ configuredBaseCwds: workspaceOptions.map((workspace) => workspace.cwd),
2943
+ availableExecutionModes: hostAvailableExecutionModes,
3018
2944
  });
2945
+ if (persistedMapping.status === 'configuration_required') {
2946
+ throw new ExecutionEnvironmentError(persistedMapping.message, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
2947
+ }
2948
+ if (persistedMapping.status === 'adopted'
2949
+ && (persistedMapping.state.baseCwd !== environment.baseCwd
2950
+ || persistedMapping.state.executionMode !== environment.mode)) {
2951
+ const restoredEnvironment = prepareConversationEnvironment({
2952
+ agentId,
2953
+ conversationId,
2954
+ workspaceCwd: persistedMapping.state.baseCwd,
2955
+ allowWorktrees: persistedMapping.state.executionMode === 'worktree',
2956
+ });
2957
+ if (restoredEnvironment.mode !== persistedMapping.state.executionMode) {
2958
+ releaseConversationEnvironment(restoredEnvironment);
2959
+ throw new ExecutionEnvironmentError(`Conversation ${conversationId} requires local execution mode ${persistedMapping.state.executionMode}, but workspace ${persistedMapping.state.baseCwd} can only be opened in ${restoredEnvironment.mode} mode.`, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
2960
+ }
2961
+ releaseConversationEnvironment(environment);
2962
+ environment = restoredEnvironment;
2963
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Restoring saved local runtime → ${environment.mode} (${environment.baseCwd})`);
2964
+ }
2965
+ const resumeKey = buildConversationEnvironmentKey(conversationId, environment.baseCwd);
2966
+ const persisted = persistedMapping.status === 'new'
2967
+ ? null
2968
+ : persistedMapping.state;
3019
2969
  const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
3020
2970
  const session = createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, (convoId, sdkSessionId) => {
3021
2971
  // Called when session processing loop ends — save session ID for resume
@@ -3056,10 +3006,10 @@ export async function main() {
3056
3006
  runtimeDescriptor = descriptor;
3057
3007
  void publishRuntimeHeartbeat();
3058
3008
  }, {
3059
- model: config?.model ?? runtimeModels[0]?.value,
3060
- permissionMode: config?.permissionMode ?? 'default',
3061
- effort: config?.effort,
3062
- ultracode: config?.ultracode,
3009
+ model: runtimeModels[0]?.value,
3010
+ permissionMode: 'default',
3011
+ effort: undefined,
3012
+ ultracode: undefined,
3063
3013
  availableModels: runtimeModels,
3064
3014
  availableExecutionModes: hostAvailableExecutionModes,
3065
3015
  defaultExecutionMode,
@@ -3078,6 +3028,10 @@ export async function main() {
3078
3028
  }, resumeId);
3079
3029
  sessions.set(conversationId, session);
3080
3030
  await controlPoller.baseline([conversationId]);
3031
+ await runtimeState.patchAgentSessionSnapshot(conversationId, {
3032
+ configurationStatus: 'ready',
3033
+ lastError: null,
3034
+ });
3081
3035
  return session;
3082
3036
  }
3083
3037
  catch (error) {
@@ -3163,13 +3117,17 @@ export async function main() {
3163
3117
  }
3164
3118
  catch (error) {
3165
3119
  const message = error instanceof Error ? error.message : String(error);
3166
- const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
3167
3120
  console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
3121
+ await runtimeState.patchAgentSessionSnapshot(input.conversationId, {
3122
+ configurationStatus: 'configuration_required',
3123
+ lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
3124
+ }).catch(() => { });
3168
3125
  await markQueuedMessageAcceptedForConversation(input.conversationId, m.id ?? null, shouldMarkAccepted);
3169
- await sendMessageWithRetry(client, input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
3126
+ await sendMessageWithRetry(client, input.conversationId, LOCAL_CONFIGURATION_REQUIRED_MESSAGE, {
3170
3127
  messageId: `claude-start-failed-${m.id}`,
3171
3128
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
3172
3129
  metadata: {
3130
+ runtimeStatus: 'configuration_required',
3173
3131
  turnSemantics: 'turn_complete',
3174
3132
  replyBehavior: 'suppress_auto_reply',
3175
3133
  },
@@ -3426,16 +3384,22 @@ export async function main() {
3426
3384
  }
3427
3385
  catch (error) {
3428
3386
  const message = error instanceof Error ? error.message : String(error);
3429
- const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
3430
3387
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
3388
+ await runtimeState.patchAgentSessionSnapshot(convoId, {
3389
+ configurationStatus: 'configuration_required',
3390
+ lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
3391
+ }).catch(() => { });
3431
3392
  await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
3432
- await sendMessageWithRetry(client, convoId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
3393
+ await sendMessageWithRetry(client, convoId, LOCAL_CONFIGURATION_REQUIRED_MESSAGE, {
3433
3394
  messageId: `claude-start-failed-${m.id}`,
3434
3395
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
3435
3396
  metadata: {
3397
+ turnId: `claude-start:${m.id}`,
3398
+ runtimeStatus: 'configuration_required',
3436
3399
  turnSemantics: 'turn_complete',
3437
3400
  replyBehavior: 'suppress_auto_reply',
3438
3401
  },
3402
+ ...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
3439
3403
  }).catch(() => { });
3440
3404
  return false;
3441
3405
  }
@@ -3455,7 +3419,10 @@ export async function main() {
3455
3419
  promptText,
3456
3420
  materialized: [...replyMedia.materialized, ...materialized],
3457
3421
  });
3458
- const turnModes = resolveClaudeTurnModes(participantContext);
3422
+ const turnModes = {
3423
+ ...resolveClaudeTurnModes(participantContext),
3424
+ replyAuthority: payload.replyAuthority ?? null,
3425
+ };
3459
3426
  session.enqueueInbound({
3460
3427
  type: 'user',
3461
3428
  message: {
@@ -3513,39 +3480,6 @@ export async function main() {
3513
3480
  stream.start().catch((err) => {
3514
3481
  console.error('[canon-host] SSE start error:', err instanceof Error ? err.message : err);
3515
3482
  });
3516
- // ── Control signal watcher ──
3517
- // Session control signals (model, mode, effort). A missing/closed session
3518
- // leaves the node in place; lastSeen still advances, so a stale control is
3519
- // never replayed once a session exists.
3520
- async function applyRuntimeSessionControl(event) {
3521
- const convoId = event.conversationId;
3522
- const session = sessions.get(convoId);
3523
- if (!session || session.closed)
3524
- return { consume: false };
3525
- const ctrl = event.control;
3526
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
3527
- await applyClaudeSessionControl({
3528
- model: ctrl.model,
3529
- permissionMode: ctrl.permissionMode,
3530
- effort: ctrl.effort,
3531
- ultracode: ctrl.runtimeControlValues?.ultracode,
3532
- }, {
3533
- state: session.state,
3534
- parsePermissionMode: parseClaudePermissionMode,
3535
- normalizeEffort: normalizeClaudeEffortLevel,
3536
- parseUltracode: parseClaudeUltracodeMode,
3537
- setModel: (model) => session.query.setModel(model),
3538
- setPermissionMode: (mode) => session.query.setPermissionMode(mode),
3539
- applyEffort: (level) => applyClaudeEffortLevel(session.query, level),
3540
- applyUltracode: (enabled) => applyClaudeUltracode(session.query, enabled),
3541
- setRuntimeControlError: (controlId, value, error) => session.setRuntimeControlError(controlId, value, error),
3542
- clearRuntimeControlError: (controlId) => session.clearRuntimeControlError(controlId),
3543
- // Publish the app-subscribed snapshot immediately so the control-pending
3544
- // gate clears now instead of at the next 30s heartbeat (lag fix).
3545
- publishSnapshot: () => { void publishSessionSnapshots([convoId]); },
3546
- });
3547
- session.writeState();
3548
- }
3549
3483
  // Stop/new-session signals remain pending until the Claude SDK confirms the
3550
3484
  // interrupt. `defer` deliberately makes the poller revisit the same node.
3551
3485
  async function handleRuntimeControlSignal(event) {
@@ -0,0 +1,42 @@
1
+ import type { AgentReplyAuthorityV1 } from '@canonmsg/backend-contracts';
2
+ export interface InboundReplySource {
3
+ conversationId: string;
4
+ sourceMessageId: string;
5
+ replyAuthority?: AgentReplyAuthorityV1;
6
+ }
7
+ export interface PendingInboundReplySource extends InboundReplySource {
8
+ readonly generation: number;
9
+ }
10
+ export declare class InboundReplyAuthorityError extends Error {
11
+ constructor();
12
+ }
13
+ /**
14
+ * One-shot authority for Claude channel replies.
15
+ *
16
+ * Every inbound message first supersedes the previous source. The caller then
17
+ * activates that exact source only after deciding the message is a real Claude
18
+ * turn (rather than, for example, an approval response consumed locally).
19
+ */
20
+ export declare class InboundReplyAuthority {
21
+ private readonly ttlMs;
22
+ private readonly now;
23
+ private generation;
24
+ private active;
25
+ constructor(ttlMs?: number, now?: () => number);
26
+ /** Supersede any prior authority before filtering the new inbound message. */
27
+ beginInbound(source: InboundReplySource): PendingInboundReplySource;
28
+ /** Activate a pending source only if no newer inbound has superseded it. */
29
+ authorize(source: PendingInboundReplySource): boolean;
30
+ /** Revoke an exact source without disturbing a newer inbound message. */
31
+ revoke(source: PendingInboundReplySource): void;
32
+ /** Validate a non-final in-turn action without consuming reply authority. */
33
+ validate(source: InboundReplySource): boolean;
34
+ /** Consume the current source when the model deliberately chooses silence. */
35
+ consumeNoReply(source: InboundReplySource): boolean;
36
+ /**
37
+ * Atomically claim one visible reply. A failed operation restores the claim
38
+ * only while the same inbound is still current and unexpired.
39
+ */
40
+ sendVisibleReply<T>(source: InboundReplySource, operation: (replyAuthority: AgentReplyAuthorityV1 | undefined) => Promise<T>): Promise<T>;
41
+ private getLiveActive;
42
+ }
@@ -0,0 +1,107 @@
1
+ const DEFAULT_REPLY_AUTHORITY_TTL_MS = 15 * 60 * 1000;
2
+ export class InboundReplyAuthorityError extends Error {
3
+ constructor() {
4
+ super('This reply is not authorized for the current inbound Canon message.');
5
+ this.name = 'InboundReplyAuthorityError';
6
+ }
7
+ }
8
+ function validSource(source) {
9
+ return source.conversationId.length > 0 && source.sourceMessageId.length > 0;
10
+ }
11
+ function sameSource(a, b) {
12
+ return a.conversationId === b.conversationId
13
+ && a.sourceMessageId === b.sourceMessageId;
14
+ }
15
+ /**
16
+ * One-shot authority for Claude channel replies.
17
+ *
18
+ * Every inbound message first supersedes the previous source. The caller then
19
+ * activates that exact source only after deciding the message is a real Claude
20
+ * turn (rather than, for example, an approval response consumed locally).
21
+ */
22
+ export class InboundReplyAuthority {
23
+ ttlMs;
24
+ now;
25
+ generation = 0;
26
+ active = null;
27
+ constructor(ttlMs = DEFAULT_REPLY_AUTHORITY_TTL_MS, now = Date.now) {
28
+ this.ttlMs = ttlMs;
29
+ this.now = now;
30
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
31
+ throw new TypeError('Reply authority TTL must be positive.');
32
+ }
33
+ }
34
+ /** Supersede any prior authority before filtering the new inbound message. */
35
+ beginInbound(source) {
36
+ if (!validSource(source)) {
37
+ throw new TypeError('Inbound reply authority requires a conversation and source message.');
38
+ }
39
+ this.generation += 1;
40
+ this.active = null;
41
+ return { ...source, generation: this.generation };
42
+ }
43
+ /** Activate a pending source only if no newer inbound has superseded it. */
44
+ authorize(source) {
45
+ if (source.generation !== this.generation)
46
+ return false;
47
+ this.active = {
48
+ ...source,
49
+ expiresAt: this.now() + this.ttlMs,
50
+ claimed: false,
51
+ };
52
+ return true;
53
+ }
54
+ /** Revoke an exact source without disturbing a newer inbound message. */
55
+ revoke(source) {
56
+ if (this.active?.generation === source.generation && sameSource(this.active, source)) {
57
+ this.active = null;
58
+ }
59
+ }
60
+ /** Validate a non-final in-turn action without consuming reply authority. */
61
+ validate(source) {
62
+ const active = this.getLiveActive();
63
+ return active !== null && sameSource(active, source);
64
+ }
65
+ /** Consume the current source when the model deliberately chooses silence. */
66
+ consumeNoReply(source) {
67
+ const active = this.getLiveActive();
68
+ if (!active || active.claimed || !sameSource(active, source))
69
+ return false;
70
+ this.active = null;
71
+ return true;
72
+ }
73
+ /**
74
+ * Atomically claim one visible reply. A failed operation restores the claim
75
+ * only while the same inbound is still current and unexpired.
76
+ */
77
+ async sendVisibleReply(source, operation) {
78
+ const active = this.getLiveActive();
79
+ if (!active || active.claimed || !sameSource(active, source)) {
80
+ throw new InboundReplyAuthorityError();
81
+ }
82
+ active.claimed = true;
83
+ try {
84
+ const result = await operation(active.replyAuthority);
85
+ if (this.active === active)
86
+ this.active = null;
87
+ return result;
88
+ }
89
+ catch (error) {
90
+ if (this.active === active) {
91
+ if (this.now() < active.expiresAt) {
92
+ active.claimed = false;
93
+ }
94
+ else {
95
+ this.active = null;
96
+ }
97
+ }
98
+ throw error;
99
+ }
100
+ }
101
+ getLiveActive() {
102
+ if (this.active && this.now() >= this.active.expiresAt) {
103
+ this.active = null;
104
+ }
105
+ return this.active;
106
+ }
107
+ }