@canonmsg/claude-code-plugin 0.32.0 → 0.34.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
@@ -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, formatPendingContactLifecycleContext, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, parseTurnVerbosityConfig, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, sendMessageWithRetry, sendMessageWithRetryChunked, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, readLocalRuntimeEntry, reconcileContactLifecycleEvents, recordLocalRuntimeContactLifecycleEvent, saveLocalRuntimeContactLifecycleCursor, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, resolveTurnVerbosity, shouldTriggerAgentTurn, takeLocalRuntimeContactLifecycleEvents, 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
- import { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer, } from '@canonmsg/agent-tools';
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
  }
@@ -53,6 +54,8 @@ COMMON FLAGS
53
54
  --cwd <path> Project directory to run Claude Code from
54
55
  --workspace <path> Additional project to expose in Canon
55
56
  --workspace-root <path> Discover projects under an approved root
57
+ --default-execution-mode <worktree|locked>
58
+ Default for new Canon conversations
56
59
  --runtime-visibility <normal|minimal|full>
57
60
  Detail visibility preset
58
61
  --show-runtime-detail <field>
@@ -86,12 +89,10 @@ let workspaceRoots = [];
86
89
  let workspaceRootMetadata = [];
87
90
  let runtimeModels = [];
88
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.';
89
94
  const allowedNonOwnerClaudeTools = parseAllowedNonOwnerClaudeTools(process.env.CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS);
90
- /**
91
- * Agent-developer setting, resolved once at startup. Deliberately NOT read from
92
- * `/session-config`: that path is the USER's per-conversation control plane,
93
- * and owner ruling 5 puts turn verbosity outside user control.
94
- */
95
+ /** Turn verbosity is local agent-developer configuration, resolved at startup. */
95
96
  let configuredTurnVerbosity = null;
96
97
  /**
97
98
  * `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
@@ -271,6 +272,7 @@ function buildClaudeRuntimeDescriptor(input) {
271
272
  workspaces: input.workspaces,
272
273
  workspaceRoots: input.workspaceRoots,
273
274
  executionModes: input.executionModes,
275
+ defaultExecutionMode: input.defaultExecutionMode,
274
276
  permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
275
277
  defaultPermissionMode: 'default',
276
278
  effortOptions: [...EFFORT_OPTIONS],
@@ -303,11 +305,7 @@ function buildClaudeRuntimeDescriptor(input) {
303
305
  activation: { kind: 'control', controlId: 'permissionMode', value: 'plan' },
304
306
  },
305
307
  ],
306
- admissionActions: {
307
- ...HOST_ADMISSION_ACTIONS_DISABLED,
308
- requestContact: true,
309
- reachOut: true,
310
- },
308
+ admissionActions: HOST_ADMISSION_ACTIONS_DISABLED,
311
309
  presentation: input.presentation,
312
310
  commands,
313
311
  });
@@ -419,32 +417,20 @@ function toModelOptions(models) {
419
417
  async function publishAgentRuntime(agentId, runtime, rtdb) {
420
418
  await publishHostAgentRuntime(agentId, 'claude-code', runtime, rtdb);
421
419
  }
422
- async function loadSessionConfig(conversationId, agentId, rtdb) {
423
- const config = await loadHostSessionConfig({
424
- conversationId,
425
- agentId,
426
- rtdb,
427
- extraStringFields: ['permissionMode', 'effort'],
428
- retryMissingMs: 3_000,
429
- });
430
- return {
431
- ...config,
432
- permissionMode: config?.permissionMode
433
- ? parseClaudePermissionMode(config.permissionMode) ?? undefined
434
- : undefined,
435
- effort: normalizeClaudeEffortLevel(config?.effort),
436
- ultracode: parseClaudeUltracodeMode(config?.runtimeControlValues?.ultracode),
437
- };
420
+ export function resolveSessionExecutionMode(defaultExecutionMode = 'worktree') {
421
+ return defaultExecutionMode;
438
422
  }
439
- function resolveSessionExecutionMode(config) {
440
- if (config?.executionMode)
441
- return config.executionMode;
442
- throw new ExecutionEnvironmentError('Session config is missing an execution mode.', 'Choose Isolated worktree or Use shared project before starting this coding session.');
423
+ function resolveConfiguredDefaultExecutionMode(value) {
424
+ if (value == null || value === '')
425
+ return 'worktree';
426
+ if (value === 'worktree' || value === 'locked')
427
+ return value;
428
+ throw new Error('--default-execution-mode must be worktree or locked');
443
429
  }
444
- function resolveWorkspaceCwd(config) {
445
- return resolveHostWorkspaceCwd({
430
+ function resolveWorkspaceCwd() {
431
+ return resolveConfiguredWorkspaceCwd({
446
432
  workspaceOptions,
447
- config,
433
+ workspaceId: workspaceOptions[0]?.id,
448
434
  defaultCwd: workingDir,
449
435
  });
450
436
  }
@@ -757,16 +743,6 @@ const CLAUDE_RUNTIME_CAPABILITIES = {
757
743
  */
758
744
  const NO_REPLY_VERB = 'no_reply';
759
745
  export const NO_REPLY_TOOL_NAME = `mcp__${CANON_VERB_MCP_SERVER_NAME}__${NO_REPLY_VERB}`;
760
- export const REACH_OUT_TOOL_NAME = `mcp__${CANON_VERB_MCP_SERVER_NAME}__send_to`;
761
- function ownerBoundRenderOptions(isOwner) {
762
- const admissionActions = isOwner
763
- ? { ...HOST_ADMISSION_ACTIONS_DISABLED, requestContact: true, reachOut: true }
764
- : HOST_ADMISSION_ACTIONS_DISABLED;
765
- return {
766
- admissionActions,
767
- ...(admissionActions.reachOut ? { reachOutToolName: REACH_OUT_TOOL_NAME } : {}),
768
- };
769
- }
770
746
  export function buildCanonPrompt(input) {
771
747
  return renderCodingHostInboundPrompt(buildCanonInboundFrameV1(buildCanonTurnContextV2({
772
748
  content: input.content,
@@ -799,8 +775,8 @@ function resolveClaudeTurnModes(participantContext) {
799
775
  }),
800
776
  };
801
777
  }
802
- function renderInboundContent(message, materialized, renderOptions = {}) {
803
- return renderClaudeInboundContent(message, materialized, renderOptions);
778
+ function renderInboundContent(message, materialized) {
779
+ return renderClaudeInboundContent(message, materialized);
804
780
  }
805
781
  async function materializePromptReplyContext(input) {
806
782
  if (!input.replyContext?.found || !input.replyContext.attachments?.length) {
@@ -813,27 +789,17 @@ async function materializePromptReplyContext(input) {
813
789
  });
814
790
  return {
815
791
  ...result,
816
- replyContext: withClaudeReplyContextMediaReferences(result.replyContext, result.materialized, input.renderOptions),
792
+ replyContext: withClaudeReplyContextMediaReferences(result.replyContext, result.materialized),
817
793
  };
818
794
  }
819
795
  catch (error) {
820
796
  console.error(`${input.logPrefix} Failed to materialize replied-to media:`, error instanceof Error ? error.message : error);
821
797
  return {
822
- replyContext: withClaudeReplyContextMediaReferences(input.replyContext, [], input.renderOptions),
798
+ replyContext: withClaudeReplyContextMediaReferences(input.replyContext, []),
823
799
  materialized: [],
824
800
  };
825
801
  }
826
802
  }
827
- function ownerBoundReplyContactTarget(replyContext) {
828
- const card = replyContext?.found ? replyContext.contactCard : undefined;
829
- if (!card?.userId)
830
- return undefined;
831
- return {
832
- targetUserId: card.userId,
833
- ...(card.canonContactId ? { canonContactId: card.canonContactId } : {}),
834
- sourceCardMessageId: replyContext.messageId,
835
- };
836
- }
837
803
  // ── Session factory ─────────────────────────────────────────────────
838
804
  function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
839
805
  const { cwd } = environment;
@@ -852,7 +818,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
852
818
  messageQueue.push(input);
853
819
  }
854
820
  };
855
- let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}, replyContactTarget) => {
821
+ let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => {
856
822
  sendInput(createClaudeInputEnvelope({
857
823
  kind: 'canon',
858
824
  msg,
@@ -861,7 +827,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
861
827
  markAccepted,
862
828
  isOwnerTurn,
863
829
  requestingUserId,
864
- replyContactTarget,
865
830
  ...turnModes,
866
831
  }));
867
832
  };
@@ -1008,24 +973,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1008
973
  const turnKey = session.activeInput?.turnKey;
1009
974
  if (turnKey)
1010
975
  session.silencedTurnKeys.add(turnKey);
1011
- }, {
1012
- ownerBoundCommunication: {
1013
- getContext: () => {
1014
- const input = session.activeInput;
1015
- if (!input?.isOwnerTurn || !input.sourceMessageId)
1016
- return null;
1017
- return {
1018
- isOwnerTurn: true,
1019
- conversationId,
1020
- sourceMessageId: input.sourceMessageId,
1021
- ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1022
- ...(input.replyContactTarget
1023
- ? { replyContactTarget: input.replyContactTarget }
1024
- : {}),
1025
- };
1026
- },
1027
- },
1028
- }),
976
+ }, { communication: config.communicationEnabled === true }),
1029
977
  },
1030
978
  },
1031
979
  ...(claudeCliPath ? { pathToClaudeCodeExecutable: claudeCliPath } : {}),
@@ -1214,7 +1162,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1214
1162
  environment,
1215
1163
  query: q,
1216
1164
  sendInput,
1217
- enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}, replyContactTarget) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, requestingUserId, turnModes, replyContactTarget),
1165
+ enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, requestingUserId, turnModes),
1218
1166
  state: {
1219
1167
  model: undefined,
1220
1168
  permissionMode: config.permissionMode,
@@ -1491,6 +1439,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1491
1439
  deliveryIntent: session.lastAcceptedIntent,
1492
1440
  suppressAutoReply: input.suppressAutoReply,
1493
1441
  }),
1442
+ ...(input.turn.replyAuthority ? { replyAuthority: input.turn.replyAuthority } : {}),
1494
1443
  });
1495
1444
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
1496
1445
  + (degraded.length === input.finalText.length
@@ -1558,6 +1507,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1558
1507
  messageId: finalMessageId,
1559
1508
  ...(selfContextId ? { selfContextId } : {}),
1560
1509
  metadata,
1510
+ ...(deliverTurn.replyAuthority ? { replyAuthority: deliverTurn.replyAuthority } : {}),
1561
1511
  }, {}, buildClaudeFinalChunkingOptions(resume));
1562
1512
  markFinalTurnDelivered(deliverTurn);
1563
1513
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars`
@@ -1620,6 +1570,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1620
1570
  }
1621
1571
  function sendTurnArtifactFile(file) {
1622
1572
  return sendMediaFileMessage(client, conversationId, file.path, '', {
1573
+ ...(session.activeInput?.replyAuthority
1574
+ ? { replyAuthority: session.activeInput.replyAuthority }
1575
+ : {}),
1623
1576
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1624
1577
  metadata: buildArtifactMediaMetadata(),
1625
1578
  });
@@ -1698,51 +1651,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1698
1651
  }, FINAL_DELIVERY_RETRY_MS);
1699
1652
  }
1700
1653
  function writeState() {
1701
- const runtimeControlErrors = Object.keys(session.runtimeControlErrors).length > 0
1702
- ? session.runtimeControlErrors
1703
- : null;
1704
- const controlState = {};
1705
- const appliedAt = Date.now();
1706
- if (session.state.model !== undefined) {
1707
- controlState.model = { value: session.state.model, source: 'applied', appliedAt };
1708
- }
1709
- else if (config.model !== undefined) {
1710
- controlState.model = { value: config.model, source: 'requested' };
1711
- }
1712
- if (session.state.permissionMode !== undefined) {
1713
- controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
1714
- }
1715
- else if (config.permissionMode !== undefined) {
1716
- controlState.permissionMode = { value: config.permissionMode, source: 'requested' };
1717
- }
1718
- if (session.state.effort !== undefined) {
1719
- controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
1720
- }
1721
- else if (config.effort !== undefined) {
1722
- controlState.effort = { value: config.effort, source: 'requested' };
1723
- }
1724
- if (session.state.ultracode !== undefined) {
1725
- controlState.ultracode = { value: session.state.ultracode, source: 'applied', appliedAt };
1726
- }
1727
- else if (config.ultracode !== undefined) {
1728
- controlState.ultracode = { value: config.ultracode, source: 'requested' };
1729
- }
1730
1654
  runtimeState.writeSessionState(conversationId, {
1731
- model: session.state.model,
1732
- permissionMode: session.state.permissionMode,
1733
- effort: session.state.effort,
1734
- ...(session.state.ultracode ? { runtimeControlValues: { ultracode: session.state.ultracode } } : {}),
1735
- controlState,
1736
- runtimeControlErrors,
1737
1655
  contextUsage: session.state.contextUsage,
1738
- ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
1739
- cwd,
1740
- executionMode: session.environment.mode,
1741
- ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
1742
- ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
1743
- ...(resolveExecutionFallbackReason(session.environment)
1744
- ? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
1745
- : {}),
1746
1656
  hostMode: true,
1747
1657
  isActive: true,
1748
1658
  }).catch(() => { });
@@ -1968,7 +1878,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1968
1878
  function hasInterruptibleSdkInput() {
1969
1879
  return session.activeInput !== null && session.dispatchingInput === null;
1970
1880
  }
1971
- enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}, replyContactTarget) => {
1881
+ enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => {
1972
1882
  session.lastActivity = Date.now();
1973
1883
  const input = createClaudeInputEnvelope({
1974
1884
  kind: 'canon',
@@ -1978,7 +1888,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1978
1888
  markAccepted,
1979
1889
  isOwnerTurn,
1980
1890
  requestingUserId,
1981
- replyContactTarget,
1982
1891
  ...turnModes,
1983
1892
  });
1984
1893
  const decision = decideClaudeInboundDispatch({
@@ -2430,11 +2339,13 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2430
2339
  availableModels: modelList,
2431
2340
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
2432
2341
  availableExecutionModes: config.availableExecutionModes,
2342
+ defaultExecutionMode: config.defaultExecutionMode,
2433
2343
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
2434
2344
  models: modelList,
2435
2345
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
2436
2346
  workspaceRoots: workspaceRootMetadata,
2437
2347
  executionModes: config.availableExecutionModes,
2348
+ defaultExecutionMode: config.defaultExecutionMode,
2438
2349
  presentation: config.runtimePresentation,
2439
2350
  }),
2440
2351
  });
@@ -2464,6 +2375,7 @@ export async function main() {
2464
2375
  cwd: { type: 'string' },
2465
2376
  workspace: { type: 'string', multiple: true },
2466
2377
  'workspace-root': { type: 'string', multiple: true },
2378
+ 'default-execution-mode': { type: 'string' },
2467
2379
  'runtime-visibility': { type: 'string' },
2468
2380
  'show-runtime-detail': { type: 'string', multiple: true },
2469
2381
  'hide-runtime-detail': { type: 'string', multiple: true },
@@ -2476,6 +2388,7 @@ export async function main() {
2476
2388
  env: process.env.CANON_TURN_VERBOSITY,
2477
2389
  onWarning: (message) => console.error(`[canon-host] ${message}`),
2478
2390
  });
2391
+ const defaultExecutionMode = resolveConfiguredDefaultExecutionMode(args['default-execution-mode']);
2479
2392
  workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
2480
2393
  const configuredWorkspaces = (args.workspace ?? []).filter((value) => typeof value === 'string');
2481
2394
  const configuredWorkspaceRoots = (args['workspace-root'] ?? []).filter((value) => typeof value === 'string');
@@ -2516,11 +2429,13 @@ export async function main() {
2516
2429
  let agentId;
2517
2430
  let ownerId = null;
2518
2431
  let ownerName = null;
2432
+ let communicationEnabled = false;
2519
2433
  try {
2520
2434
  const ctx = await client.getAgentMe();
2521
2435
  agentId = ctx.agentId;
2522
2436
  ownerId = ctx.ownerId;
2523
2437
  ownerName = ctx.ownerName;
2438
+ communicationEnabled = isProactiveCommunicationEnabled(ctx.outboundPolicy);
2524
2439
  console.error(`[canon-host] Connected as ${ctx.displayName || agentId}`);
2525
2440
  }
2526
2441
  catch {
@@ -2567,15 +2482,6 @@ export async function main() {
2567
2482
  hostMode: true,
2568
2483
  rtdb,
2569
2484
  });
2570
- const recoveryCheckpointTrackers = new Map();
2571
- function recoveryCheckpointsFor(conversationId) {
2572
- let tracker = recoveryCheckpointTrackers.get(conversationId);
2573
- if (!tracker) {
2574
- tracker = createClaudeRecoveryCheckpointTracker((messageId) => persistConversationRecoveryCursor(conversationId, messageId));
2575
- recoveryCheckpointTrackers.set(conversationId, tracker);
2576
- }
2577
- return tracker;
2578
- }
2579
2485
  let streamConnected = false;
2580
2486
  const hostAvailableExecutionModes = [
2581
2487
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -2587,6 +2493,7 @@ export async function main() {
2587
2493
  hide: stringArgs(args['hide-runtime-detail']),
2588
2494
  });
2589
2495
  let runtimeDescriptor = {
2496
+ defaultExecutionMode,
2590
2497
  defaultWorkspaceId: workspaceOptions[0]?.id,
2591
2498
  defaultPermissionMode: 'default',
2592
2499
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
@@ -2597,6 +2504,7 @@ export async function main() {
2597
2504
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
2598
2505
  workspaceRoots: workspaceRootMetadata,
2599
2506
  executionModes: hostAvailableExecutionModes,
2507
+ defaultExecutionMode,
2600
2508
  presentation: runtimePresentation,
2601
2509
  }),
2602
2510
  };
@@ -2617,25 +2525,6 @@ export async function main() {
2617
2525
  agentId,
2618
2526
  rtdb,
2619
2527
  clientType: 'claude-code',
2620
- runtime: runtimeDescriptor,
2621
- workspaceOptions,
2622
- defaultCwd: workingDir,
2623
- extraSessionConfigFields: ['permissionMode', 'effort'],
2624
- liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
2625
- const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
2626
- return [
2627
- session.conversationId,
2628
- {
2629
- ...(session.state.model ? { model: session.state.model } : {}),
2630
- ...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
2631
- ...(session.state.effort ? { effort: session.state.effort } : {}),
2632
- ...(session.state.ultracode ? { runtimeControlValues: { ultracode: session.state.ultracode } } : {}),
2633
- ...(workspaceId ? { workspaceId } : {}),
2634
- executionMode: session.environment.mode,
2635
- executionBranch: session.environment.branch ?? null,
2636
- },
2637
- ];
2638
- })),
2639
2528
  }).catch((error) => {
2640
2529
  console.error('[canon-host] Failed to publish session snapshots:', error);
2641
2530
  });
@@ -2675,6 +2564,7 @@ export async function main() {
2675
2564
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
2676
2565
  workspaceRoots: workspaceRootMetadata,
2677
2566
  executionModes: hostAvailableExecutionModes,
2567
+ defaultExecutionMode,
2678
2568
  presentation: runtimePresentation,
2679
2569
  }),
2680
2570
  };
@@ -2709,6 +2599,7 @@ export async function main() {
2709
2599
  try {
2710
2600
  runtimeModels = await detectRuntimeModels(workingDir);
2711
2601
  runtimeDescriptor = {
2602
+ defaultExecutionMode,
2712
2603
  defaultWorkspaceId: workspaceOptions[0]?.id,
2713
2604
  defaultModel: runtimeModels[0]?.value,
2714
2605
  defaultPermissionMode: 'default',
@@ -2721,12 +2612,14 @@ export async function main() {
2721
2612
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
2722
2613
  workspaceRoots: workspaceRootMetadata,
2723
2614
  executionModes: hostAvailableExecutionModes,
2615
+ defaultExecutionMode,
2724
2616
  presentation: runtimePresentation,
2725
2617
  }),
2726
2618
  };
2727
2619
  }
2728
2620
  catch {
2729
2621
  runtimeDescriptor = {
2622
+ defaultExecutionMode,
2730
2623
  defaultWorkspaceId: workspaceOptions[0]?.id,
2731
2624
  defaultPermissionMode: 'default',
2732
2625
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
@@ -2737,6 +2630,7 @@ export async function main() {
2737
2630
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
2738
2631
  workspaceRoots: workspaceRootMetadata,
2739
2632
  executionModes: hostAvailableExecutionModes,
2633
+ defaultExecutionMode,
2740
2634
  presentation: runtimePresentation,
2741
2635
  }),
2742
2636
  };
@@ -2759,14 +2653,23 @@ export async function main() {
2759
2653
  }
2760
2654
  // ── Session manager ──
2761
2655
  const sessions = new Map();
2656
+ const recoveryCheckpointTrackers = new Map();
2657
+ function recoveryCheckpointsFor(conversationId) {
2658
+ let tracker = recoveryCheckpointTrackers.get(conversationId);
2659
+ if (!tracker) {
2660
+ tracker = createClaudeRecoveryCheckpointTracker((messageId) => persistConversationRecoveryCursor(conversationId, messageId));
2661
+ recoveryCheckpointTrackers.set(conversationId, tracker);
2662
+ }
2663
+ return tracker;
2664
+ }
2762
2665
  const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
2763
2666
  // Interactive runtime input (AskUserQuestion) works with or without an owner,
2764
2667
  // so its poll engine is always present. Create + poll fold onto the built-in
2765
2668
  // input descriptor via `request()` (see the AskUserQuestion canUseTool branch).
2766
2669
  const runtimeInputManager = new RuntimeRequestManager(client, { agentId, ownerId: ownerId ?? '' });
2767
2670
  const pendingSessionCreations = new Map();
2768
- // Shared /control channel poller (claude host profile): session + signal
2769
- // 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
2770
2673
  // cycle start, eager baselines at session creation, and nodes left in place
2771
2674
  // when a handler throws (consumeOnError defaults to false for both keys).
2772
2675
  const controlPoller = new ControlChannelPoller({
@@ -2788,7 +2691,6 @@ export async function main() {
2788
2691
  pollOnStart: true,
2789
2692
  conversationConcurrency: 'sequential',
2790
2693
  handlers: {
2791
- session: { handle: applyRuntimeSessionControl },
2792
2694
  signal: { handle: handleRuntimeControlSignal },
2793
2695
  },
2794
2696
  onError: ({ scope, key, conversationId, error }) => {
@@ -2887,7 +2789,6 @@ export async function main() {
2887
2789
  ownerName,
2888
2790
  membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
2889
2791
  groupContextMode: getGroupContextMode(input.conversationId, conversation),
2890
- renderOptions: input.renderOptions,
2891
2792
  });
2892
2793
  }
2893
2794
  function closeSession(conversationId, options = {}) {
@@ -3024,22 +2925,47 @@ export async function main() {
3024
2925
  evictOldestIdle();
3025
2926
  }
3026
2927
  const creation = (async () => {
3027
- const config = await loadSessionConfig(conversationId, agentId, rtdb);
3028
- const sessionExecutionMode = resolveSessionExecutionMode(config);
3029
- const workspaceCwd = resolveWorkspaceCwd(config);
3030
- const environment = prepareConversationEnvironment({
2928
+ const sessionExecutionMode = resolveSessionExecutionMode(defaultExecutionMode);
2929
+ const workspaceCwd = resolveWorkspaceCwd();
2930
+ let environment = prepareConversationEnvironment({
3031
2931
  agentId,
3032
2932
  conversationId,
3033
2933
  workspaceCwd,
3034
2934
  allowWorktrees: sessionExecutionMode === 'worktree',
3035
2935
  });
3036
2936
  try {
3037
- const resumeKey = buildConversationEnvironmentKey(conversationId, environment.baseCwd);
3038
- const persisted = loadRuntimeSessionState(runtimeId, {
2937
+ const persistedMapping = resolveLocalRuntimeSessionState(runtimeId, {
3039
2938
  conversationId,
3040
2939
  baseCwd: environment.baseCwd,
3041
2940
  executionMode: environment.mode,
2941
+ resumeField: 'claudeSessionId',
2942
+ configuredBaseCwds: workspaceOptions.map((workspace) => workspace.cwd),
2943
+ availableExecutionModes: hostAvailableExecutionModes,
3042
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;
3043
2969
  const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
3044
2970
  const session = createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, (convoId, sdkSessionId) => {
3045
2971
  // Called when session processing loop ends — save session ID for resume
@@ -3080,18 +3006,20 @@ export async function main() {
3080
3006
  runtimeDescriptor = descriptor;
3081
3007
  void publishRuntimeHeartbeat();
3082
3008
  }, {
3083
- model: config?.model ?? runtimeModels[0]?.value,
3084
- permissionMode: config?.permissionMode ?? 'default',
3085
- effort: config?.effort,
3086
- ultracode: config?.ultracode,
3009
+ model: runtimeModels[0]?.value,
3010
+ permissionMode: 'default',
3011
+ effort: undefined,
3012
+ ultracode: undefined,
3087
3013
  availableModels: runtimeModels,
3088
3014
  availableExecutionModes: hostAvailableExecutionModes,
3015
+ defaultExecutionMode,
3089
3016
  runtimePresentation,
3090
3017
  approvalManager,
3091
3018
  runtimeInputManager,
3092
3019
  canRequestApproval: () => canRequestCanonApproval(conversationId),
3093
3020
  runtimeId,
3094
3021
  ownerId,
3022
+ communicationEnabled,
3095
3023
  onInputCompleted: (sourceMessageId) => {
3096
3024
  recoveryCheckpointsFor(conversationId).settle(sourceMessageId);
3097
3025
  settleInboundMessageId(sourceMessageId, true);
@@ -3100,6 +3028,10 @@ export async function main() {
3100
3028
  }, resumeId);
3101
3029
  sessions.set(conversationId, session);
3102
3030
  await controlPoller.baseline([conversationId]);
3031
+ await runtimeState.patchAgentSessionSnapshot(conversationId, {
3032
+ configurationStatus: 'ready',
3033
+ lastError: null,
3034
+ });
3103
3035
  return session;
3104
3036
  }
3105
3037
  catch (error) {
@@ -3151,15 +3083,13 @@ export async function main() {
3151
3083
  }
3152
3084
  const sender = m.senderName || m.senderId;
3153
3085
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
3154
- const renderOptions = ownerBoundRenderOptions(isOwner);
3155
- const content = renderInboundContent(m, materialized, renderOptions);
3086
+ const content = renderInboundContent(m, materialized);
3156
3087
  const hydrated = await loadHydratedInboundContext({
3157
3088
  conversationId: input.conversationId,
3158
3089
  message: m,
3159
3090
  senderName: sender,
3160
3091
  isOwner,
3161
3092
  hydratedPage: input.hydratedPage,
3162
- renderOptions,
3163
3093
  });
3164
3094
  const behavior = input.hydratedPage?.behavior ?? hydrated.behavior;
3165
3095
  const activeSelfContextId = hydrated.activeSelfContextId;
@@ -3169,7 +3099,6 @@ export async function main() {
3169
3099
  agentId,
3170
3100
  conversationId: input.conversationId,
3171
3101
  logPrefix: `[canon-host] [${input.conversationId.slice(0, 8)}]`,
3172
- renderOptions,
3173
3102
  });
3174
3103
  const replyContext = replyMedia.replyContext;
3175
3104
  const participantContext = hydrated.participantContext;
@@ -3188,13 +3117,17 @@ export async function main() {
3188
3117
  }
3189
3118
  catch (error) {
3190
3119
  const message = error instanceof Error ? error.message : String(error);
3191
- const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
3192
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(() => { });
3193
3125
  await markQueuedMessageAcceptedForConversation(input.conversationId, m.id ?? null, shouldMarkAccepted);
3194
- 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, {
3195
3127
  messageId: `claude-start-failed-${m.id}`,
3196
3128
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
3197
3129
  metadata: {
3130
+ runtimeStatus: 'configuration_required',
3198
3131
  turnSemantics: 'turn_complete',
3199
3132
  replyBehavior: 'suppress_auto_reply',
3200
3133
  },
@@ -3202,7 +3135,7 @@ export async function main() {
3202
3135
  return 'handled';
3203
3136
  }
3204
3137
  session.activeSelfContextId = activeSelfContextId;
3205
- const basePromptText = buildCanonPrompt({
3138
+ const promptText = buildCanonPrompt({
3206
3139
  content,
3207
3140
  conversationId: input.conversationId,
3208
3141
  participantContext,
@@ -3213,12 +3146,6 @@ export async function main() {
3213
3146
  replyContext,
3214
3147
  message: m,
3215
3148
  });
3216
- const lifecycleContext = isOwner
3217
- ? formatPendingContactLifecycleContext(takeLocalRuntimeContactLifecycleEvents(runtimeId, input.conversationId))
3218
- : null;
3219
- const promptText = lifecycleContext
3220
- ? `${basePromptText}\n\n${lifecycleContext}`
3221
- : basePromptText;
3222
3149
  const messageContent = await buildCanonUserContent({
3223
3150
  promptText,
3224
3151
  materialized: [...replyMedia.materialized, ...materialized],
@@ -3236,7 +3163,7 @@ export async function main() {
3236
3163
  senderId: m.senderId,
3237
3164
  senderName: m.senderName,
3238
3165
  }),
3239
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes, ownerBoundReplyContactTarget(replyContext));
3166
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
3240
3167
  return 'queued';
3241
3168
  }
3242
3169
  const acceptedInboundMessageIds = new Set();
@@ -3316,27 +3243,6 @@ export async function main() {
3316
3243
  const conversationsDiscoveredWhileOffline = startupRecoveryComplete
3317
3244
  ? new Set([...knownConversationIds].filter((id) => !knownBeforeRefresh.has(id)))
3318
3245
  : new Set();
3319
- try {
3320
- let cursor = readLocalRuntimeEntry(runtimeId)?.contactLifecycleCursor ?? null;
3321
- for (let pageNumber = 0; pageNumber < 10; pageNumber += 1) {
3322
- const page = await client.listContactRequestLifecyclePage({
3323
- cursor,
3324
- limit: 100,
3325
- });
3326
- await reconcileContactLifecycleEvents({
3327
- requests: page.requests,
3328
- requesterId: agentId,
3329
- record: (request) => recordLocalRuntimeContactLifecycleEvent(runtimeId, request),
3330
- });
3331
- cursor = page.nextCursor;
3332
- saveLocalRuntimeContactLifecycleCursor(runtimeId, cursor);
3333
- if (!page.hasMore)
3334
- break;
3335
- }
3336
- }
3337
- catch (error) {
3338
- console.error('[canon-host] Contact lifecycle recovery failed:', error instanceof Error ? error.message : error);
3339
- }
3340
3246
  for (const conversationId of knownConversationIds) {
3341
3247
  const recoveryCheckpoints = recoveryCheckpointsFor(conversationId);
3342
3248
  const recoveryBatch = recoveryCheckpoints.reserveBatch();
@@ -3436,8 +3342,7 @@ export async function main() {
3436
3342
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
3437
3343
  }
3438
3344
  }
3439
- const renderOptions = ownerBoundRenderOptions(isOwner);
3440
- const content = renderInboundContent(m, materialized, renderOptions);
3345
+ const content = renderInboundContent(m, materialized);
3441
3346
  const hydrated = await loadHydratedInboundContext({
3442
3347
  conversationId: convoId,
3443
3348
  message: m,
@@ -3446,7 +3351,6 @@ export async function main() {
3446
3351
  activeSelfContextId: payload.activeSelfContextId,
3447
3352
  selfContexts: payload.selfContexts,
3448
3353
  provenance: payload.provenance,
3449
- renderOptions,
3450
3354
  });
3451
3355
  const behavior = payload.behavior ?? hydrated.behavior;
3452
3356
  const activeSelfContextId = hydrated.activeSelfContextId;
@@ -3456,7 +3360,6 @@ export async function main() {
3456
3360
  agentId,
3457
3361
  conversationId: convoId,
3458
3362
  logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
3459
- renderOptions,
3460
3363
  });
3461
3364
  const replyContext = replyMedia.replyContext;
3462
3365
  const participantContext = hydrated.participantContext;
@@ -3481,21 +3384,27 @@ export async function main() {
3481
3384
  }
3482
3385
  catch (error) {
3483
3386
  const message = error instanceof Error ? error.message : String(error);
3484
- const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
3485
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(() => { });
3486
3392
  await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
3487
- await sendMessageWithRetry(client, convoId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
3393
+ await sendMessageWithRetry(client, convoId, LOCAL_CONFIGURATION_REQUIRED_MESSAGE, {
3488
3394
  messageId: `claude-start-failed-${m.id}`,
3489
3395
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
3490
3396
  metadata: {
3397
+ turnId: `claude-start:${m.id}`,
3398
+ runtimeStatus: 'configuration_required',
3491
3399
  turnSemantics: 'turn_complete',
3492
3400
  replyBehavior: 'suppress_auto_reply',
3493
3401
  },
3402
+ ...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
3494
3403
  }).catch(() => { });
3495
3404
  return false;
3496
3405
  }
3497
3406
  session.activeSelfContextId = activeSelfContextId;
3498
- const basePromptText = buildCanonPrompt({
3407
+ const promptText = buildCanonPrompt({
3499
3408
  content,
3500
3409
  conversationId: convoId,
3501
3410
  participantContext,
@@ -3506,17 +3415,14 @@ export async function main() {
3506
3415
  replyContext,
3507
3416
  message: m,
3508
3417
  });
3509
- const lifecycleContext = isOwner
3510
- ? formatPendingContactLifecycleContext(takeLocalRuntimeContactLifecycleEvents(runtimeId, convoId))
3511
- : null;
3512
- const promptText = lifecycleContext
3513
- ? `${basePromptText}\n\n${lifecycleContext}`
3514
- : basePromptText;
3515
3418
  const messageContent = await buildCanonUserContent({
3516
3419
  promptText,
3517
3420
  materialized: [...replyMedia.materialized, ...materialized],
3518
3421
  });
3519
- const turnModes = resolveClaudeTurnModes(participantContext);
3422
+ const turnModes = {
3423
+ ...resolveClaudeTurnModes(participantContext),
3424
+ replyAuthority: payload.replyAuthority ?? null,
3425
+ };
3520
3426
  session.enqueueInbound({
3521
3427
  type: 'user',
3522
3428
  message: {
@@ -3529,7 +3435,7 @@ export async function main() {
3529
3435
  senderId: m.senderId,
3530
3436
  senderName: m.senderName,
3531
3437
  }),
3532
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes, ownerBoundReplyContactTarget(replyContext));
3438
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
3533
3439
  return true;
3534
3440
  })().then((queued) => {
3535
3441
  if (!queued) {
@@ -3547,24 +3453,20 @@ export async function main() {
3547
3453
  onConversationUpdated: (payload) => {
3548
3454
  handleConversationUpdated(payload);
3549
3455
  },
3550
- onContactRequestUpdated: (payload) => {
3551
- if (payload.requesterId !== agentId || !payload.sourceConversationId)
3552
- return;
3553
- recordLocalRuntimeContactLifecycleEvent(runtimeId, payload);
3554
- },
3555
3456
  onReplayExpired: () => {
3556
3457
  void reconnectRecovery.onReplayExpired().catch((error) => {
3557
- console.error('[canon-host] Replay-expired recovery failed:', error);
3458
+ console.error('[canon-host] Replay-expiry recovery failed:', error instanceof Error ? error.message : error);
3558
3459
  });
3559
3460
  },
3560
3461
  onConnected: () => {
3561
3462
  streamConnected = true;
3562
3463
  void publishRuntimeHeartbeat();
3563
3464
  const recovery = reconnectRecovery.onConnected();
3564
- if (recovery)
3465
+ if (recovery) {
3565
3466
  void recovery.catch((error) => {
3566
- console.error('[canon-host] Reconnect recovery failed:', error);
3467
+ console.error('[canon-host] Reconnect recovery failed:', error instanceof Error ? error.message : error);
3567
3468
  });
3469
+ }
3568
3470
  console.error('[canon-host] SSE connected');
3569
3471
  },
3570
3472
  onDisconnected: () => {
@@ -3578,39 +3480,6 @@ export async function main() {
3578
3480
  stream.start().catch((err) => {
3579
3481
  console.error('[canon-host] SSE start error:', err instanceof Error ? err.message : err);
3580
3482
  });
3581
- // ── Control signal watcher ──
3582
- // Session control signals (model, mode, effort). A missing/closed session
3583
- // leaves the node in place; lastSeen still advances, so a stale control is
3584
- // never replayed once a session exists.
3585
- async function applyRuntimeSessionControl(event) {
3586
- const convoId = event.conversationId;
3587
- const session = sessions.get(convoId);
3588
- if (!session || session.closed)
3589
- return { consume: false };
3590
- const ctrl = event.control;
3591
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
3592
- await applyClaudeSessionControl({
3593
- model: ctrl.model,
3594
- permissionMode: ctrl.permissionMode,
3595
- effort: ctrl.effort,
3596
- ultracode: ctrl.runtimeControlValues?.ultracode,
3597
- }, {
3598
- state: session.state,
3599
- parsePermissionMode: parseClaudePermissionMode,
3600
- normalizeEffort: normalizeClaudeEffortLevel,
3601
- parseUltracode: parseClaudeUltracodeMode,
3602
- setModel: (model) => session.query.setModel(model),
3603
- setPermissionMode: (mode) => session.query.setPermissionMode(mode),
3604
- applyEffort: (level) => applyClaudeEffortLevel(session.query, level),
3605
- applyUltracode: (enabled) => applyClaudeUltracode(session.query, enabled),
3606
- setRuntimeControlError: (controlId, value, error) => session.setRuntimeControlError(controlId, value, error),
3607
- clearRuntimeControlError: (controlId) => session.clearRuntimeControlError(controlId),
3608
- // Publish the app-subscribed snapshot immediately so the control-pending
3609
- // gate clears now instead of at the next 30s heartbeat (lag fix).
3610
- publishSnapshot: () => { void publishSessionSnapshots([convoId]); },
3611
- });
3612
- session.writeState();
3613
- }
3614
3483
  // Stop/new-session signals remain pending until the Claude SDK confirms the
3615
3484
  // interrupt. `defer` deliberately makes the poller revisit the same node.
3616
3485
  async function handleRuntimeControlSignal(event) {