@canonmsg/claude-code-plugin 0.31.3 → 0.32.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.
@@ -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.31.3",
4
+ "version": "0.32.0",
5
5
  "channels": [
6
6
  {
7
7
  "server": "canon-channel",
package/README.md CHANGED
@@ -89,6 +89,11 @@ canon-claude --cwd /path/to/project --turn-verbosity quiet
89
89
  | `verbose` | Live streaming text plus the margin activity rows on the final, everywhere |
90
90
  | `quiet` | The thinking indicator and the answer, nothing in between, everywhere |
91
91
 
92
+ In verbose mode, authored text remains continual across tool use: text before a
93
+ tool stays as its own speech bubble, the tool appears in the activity margin,
94
+ and text after the result resumes in a new bubble. These are ephemeral streaming
95
+ updates; only the final durable `turn_complete` message is notification-eligible.
96
+
92
97
  Quiet drops the live `/streaming` narration and the final's `turnTrail` activity rows. It does **not** drop the thinking indicator (which now stays up for the turn's whole working phase rather than handing over to a bubble that never appears; while the turn is parked on an approval the clients suppress an agent's dots and the header line carries the state), the turn state, the answer — including every part of a long chunked one — failure notices, generated files, or approval and question cards and their receipts.
93
98
 
94
99
  This is an agent-developer setting. Canon never changes it, and it is deliberately not part of the per-conversation session config a user can edit. `canon-necromance` replays a stored launch command verbatim, so add the flag at registration time if you want a non-default value.
@@ -1,6 +1,6 @@
1
1
  import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
2
  import { type AnthropicImageBudgetOptions, type MaterializedCanonAttachment } from '@canonmsg/agent-sdk';
3
- import { renderCanonHostInboundContent, type CanonReplyContext } from '@canonmsg/core';
3
+ import { renderCanonHostInboundContent, type CanonReplyContext, type HostInboundContentRenderOptions } from '@canonmsg/core';
4
4
  type ClaudeRenderableInboundMessage = Parameters<typeof renderCanonHostInboundContent>[0];
5
5
  /**
6
6
  * Claude-only rendering policy for Canon media. Core deliberately omits all
@@ -8,8 +8,8 @@ type ClaudeRenderableInboundMessage = Parameters<typeof renderCanonHostInboundCo
8
8
  * that the bounded runtime did not materialize, while materialized images keep
9
9
  * their local-path placeholder without a duplicate link.
10
10
  */
11
- export declare function renderClaudeInboundContent(message: ClaudeRenderableInboundMessage, materialized?: ReadonlyArray<MaterializedCanonAttachment>): string;
12
- export declare function withClaudeReplyContextMediaReferences(replyContext: CanonReplyContext | null, materialized: ReadonlyArray<MaterializedCanonAttachment>): CanonReplyContext | null;
11
+ export declare function renderClaudeInboundContent(message: ClaudeRenderableInboundMessage, materialized?: ReadonlyArray<MaterializedCanonAttachment>, renderOptions?: HostInboundContentRenderOptions): string;
12
+ export declare function withClaudeReplyContextMediaReferences(replyContext: CanonReplyContext | null, materialized: ReadonlyArray<MaterializedCanonAttachment>, renderOptions?: HostInboundContentRenderOptions): CanonReplyContext | null;
13
13
  /**
14
14
  * Build the Claude Code SDK's multimodal user content without allowing native
15
15
  * image blocks to consume the whole request. The prompt text is preserved
@@ -28,8 +28,8 @@ function validatedPromptAttachmentUrl(value) {
28
28
  * that the bounded runtime did not materialize, while materialized images keep
29
29
  * their local-path placeholder without a duplicate link.
30
30
  */
31
- export function renderClaudeInboundContent(message, materialized = []) {
32
- const rendered = renderCanonHostInboundContent(message, materialized);
31
+ export function renderClaudeInboundContent(message, materialized = [], renderOptions = {}) {
32
+ const rendered = renderCanonHostInboundContent(message, materialized, renderOptions);
33
33
  const materializedIndexes = new Set(materialized.map((attachment) => attachment.index));
34
34
  const imageLinks = (message.attachments ?? []).flatMap((attachment, index) => {
35
35
  if (attachment.kind !== 'image' || materializedIndexes.has(index))
@@ -39,7 +39,7 @@ export function renderClaudeInboundContent(message, materialized = []) {
39
39
  });
40
40
  return imageLinks.length > 0 ? `${rendered}\n${imageLinks.join('\n')}` : rendered;
41
41
  }
42
- export function withClaudeReplyContextMediaReferences(replyContext, materialized) {
42
+ export function withClaudeReplyContextMediaReferences(replyContext, materialized, renderOptions = {}) {
43
43
  if (!replyContext?.found)
44
44
  return replyContext;
45
45
  return {
@@ -50,7 +50,7 @@ export function withClaudeReplyContextMediaReferences(replyContext, materialized
50
50
  attachments: replyContext.attachments,
51
51
  contactCard: replyContext.contactCard,
52
52
  senderType: replyContext.senderType ?? undefined,
53
- }, materialized),
53
+ }, materialized, renderOptions),
54
54
  };
55
55
  }
56
56
  /**
package/dist/host.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { type PermissionResult, type SDKControlReloadPluginsResponse } from '@anthropic-ai/claude-agent-sdk';
3
+ import { type RecoveryCheckpointTracker } from '@canonmsg/coding-agent-host';
3
4
  import { type CanonRuntimeCommandDescriptor, type HostInboundParticipantContext as InboundParticipantContext, type CanonReplyContext, type MessageCreatedPayload, type ResolvedAgentBehaviorPolicy, type TurnVerbosityConfig } from '@canonmsg/core';
4
5
  /**
5
6
  * `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
@@ -24,7 +25,9 @@ export declare function buildClaudePlanAllowResult(input: Record<string, unknown
24
25
  * through. Canon-injected aliases keep precedence over native names.
25
26
  */
26
27
  export declare function buildNativeSlashCommandDescriptors(native: SDKControlReloadPluginsResponse['commands'], reservedAliases: ReadonlySet<string>): CanonRuntimeCommandDescriptor[];
28
+ export declare function createClaudeRecoveryCheckpointTracker(persist: (messageId: string) => boolean): RecoveryCheckpointTracker;
27
29
  export declare const NO_REPLY_TOOL_NAME: string;
30
+ export declare const REACH_OUT_TOOL_NAME = "mcp__canon__send_to";
28
31
  export declare function buildCanonPrompt(input: {
29
32
  content: string;
30
33
  conversationId: string;
package/dist/host.js CHANGED
@@ -27,14 +27,14 @@ import { existsSync } from 'node:fs';
27
27
  import { readFile } from 'node:fs/promises';
28
28
  import { query, } from '@anthropic-ai/claude-agent-sdk';
29
29
  import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
30
- import { captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, collectMissedInboundMessages, createReconnectRecoveryGate, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from '@canonmsg/coding-agent-host';
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, 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';
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, claudeFinalWillChunk, classifyFinalDeliveryFailure, claudeInputOwnsTurnSlot, createClaudeTurnActivityState, decideClaudeControlSignalAction, decideClaudeInboundDispatch, dispatchClaudeInput, 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 { 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';
38
38
  import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } from './user-dialog.js';
39
39
  function parseRuntimeVisibilityPreset(value) {
40
40
  return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
@@ -303,7 +303,11 @@ function buildClaudeRuntimeDescriptor(input) {
303
303
  activation: { kind: 'control', controlId: 'permissionMode', value: 'plan' },
304
304
  },
305
305
  ],
306
- admissionActions: HOST_ADMISSION_ACTIONS_DISABLED,
306
+ admissionActions: {
307
+ ...HOST_ADMISSION_ACTIONS_DISABLED,
308
+ requestContact: true,
309
+ reachOut: true,
310
+ },
307
311
  presentation: input.presentation,
308
312
  commands,
309
313
  });
@@ -421,6 +425,7 @@ async function loadSessionConfig(conversationId, agentId, rtdb) {
421
425
  agentId,
422
426
  rtdb,
423
427
  extraStringFields: ['permissionMode', 'effort'],
428
+ retryMissingMs: 3_000,
424
429
  });
425
430
  return {
426
431
  ...config,
@@ -728,6 +733,9 @@ const FINAL_DELIVERY_RETRY_MS = 30_000;
728
733
  // without delaying anything that was going to fail anyway.
729
734
  const MAX_FINAL_DELIVERY_RETRIES = 2;
730
735
  const CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID = 'claude-recovery-cursor-v1';
736
+ export function createClaudeRecoveryCheckpointTracker(persist) {
737
+ return createRecoveryCheckpointTracker(persist);
738
+ }
731
739
  const CLAUDE_RUNTIME_CAPABILITIES = {
732
740
  ...DEFAULT_RUNTIME_CAPABILITIES,
733
741
  supportsInterrupt: true,
@@ -749,6 +757,16 @@ const CLAUDE_RUNTIME_CAPABILITIES = {
749
757
  */
750
758
  const NO_REPLY_VERB = 'no_reply';
751
759
  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
+ }
752
770
  export function buildCanonPrompt(input) {
753
771
  return renderCodingHostInboundPrompt(buildCanonInboundFrameV1(buildCanonTurnContextV2({
754
772
  content: input.content,
@@ -781,8 +799,8 @@ function resolveClaudeTurnModes(participantContext) {
781
799
  }),
782
800
  };
783
801
  }
784
- function renderInboundContent(message, materialized) {
785
- return renderClaudeInboundContent(message, materialized);
802
+ function renderInboundContent(message, materialized, renderOptions = {}) {
803
+ return renderClaudeInboundContent(message, materialized, renderOptions);
786
804
  }
787
805
  async function materializePromptReplyContext(input) {
788
806
  if (!input.replyContext?.found || !input.replyContext.attachments?.length) {
@@ -795,17 +813,27 @@ async function materializePromptReplyContext(input) {
795
813
  });
796
814
  return {
797
815
  ...result,
798
- replyContext: withClaudeReplyContextMediaReferences(result.replyContext, result.materialized),
816
+ replyContext: withClaudeReplyContextMediaReferences(result.replyContext, result.materialized, input.renderOptions),
799
817
  };
800
818
  }
801
819
  catch (error) {
802
820
  console.error(`${input.logPrefix} Failed to materialize replied-to media:`, error instanceof Error ? error.message : error);
803
821
  return {
804
- replyContext: withClaudeReplyContextMediaReferences(input.replyContext, []),
822
+ replyContext: withClaudeReplyContextMediaReferences(input.replyContext, [], input.renderOptions),
805
823
  materialized: [],
806
824
  };
807
825
  }
808
826
  }
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
+ }
809
837
  // ── Session factory ─────────────────────────────────────────────────
810
838
  function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
811
839
  const { cwd } = environment;
@@ -824,7 +852,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
824
852
  messageQueue.push(input);
825
853
  }
826
854
  };
827
- let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => {
855
+ let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}, replyContactTarget) => {
828
856
  sendInput(createClaudeInputEnvelope({
829
857
  kind: 'canon',
830
858
  msg,
@@ -833,6 +861,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
833
861
  markAccepted,
834
862
  isOwnerTurn,
835
863
  requestingUserId,
864
+ replyContactTarget,
836
865
  ...turnModes,
837
866
  }));
838
867
  };
@@ -979,6 +1008,23 @@ function createSession(conversationId, environment, agentId, client, typingSigna
979
1008
  const turnKey = session.activeInput?.turnKey;
980
1009
  if (turnKey)
981
1010
  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
+ },
982
1028
  }),
983
1029
  },
984
1030
  },
@@ -1168,7 +1214,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1168
1214
  environment,
1169
1215
  query: q,
1170
1216
  sendInput,
1171
- enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, requestingUserId, turnModes),
1217
+ enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}, replyContactTarget) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, requestingUserId, turnModes, replyContactTarget),
1172
1218
  state: {
1173
1219
  model: undefined,
1174
1220
  permissionMode: config.permissionMode,
@@ -1260,19 +1306,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1260
1306
  function markInputCompleted(input) {
1261
1307
  if (input?.kind !== 'canon' || !input.sourceMessageId)
1262
1308
  return;
1263
- if (config.runtimeId) {
1264
- try {
1265
- saveRuntimeSessionState(config.runtimeId, {
1266
- conversationId,
1267
- baseCwd: config.recoveryBaseCwd ?? environment.baseCwd,
1268
- workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
1269
- lastInboundMessageId: input.sourceMessageId,
1270
- });
1271
- }
1272
- catch (error) {
1273
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
1274
- }
1275
- }
1276
1309
  config.onInputCompleted?.(input.sourceMessageId);
1277
1310
  }
1278
1311
  /**
@@ -1935,7 +1968,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1935
1968
  function hasInterruptibleSdkInput() {
1936
1969
  return session.activeInput !== null && session.dispatchingInput === null;
1937
1970
  }
1938
- enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => {
1971
+ enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}, replyContactTarget) => {
1939
1972
  session.lastActivity = Date.now();
1940
1973
  const input = createClaudeInputEnvelope({
1941
1974
  kind: 'canon',
@@ -1945,6 +1978,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1945
1978
  markAccepted,
1946
1979
  isOwnerTurn,
1947
1980
  requestingUserId,
1981
+ replyContactTarget,
1948
1982
  ...turnModes,
1949
1983
  });
1950
1984
  const decision = decideClaudeInboundDispatch({
@@ -2066,7 +2100,17 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2066
2100
  // being written.
2067
2101
  if (!isClaudeMainTurnMessage(msg.parent_tool_use_id))
2068
2102
  break;
2069
- if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
2103
+ if (event?.type === 'message_start') {
2104
+ beginClaudeAssistantResponse(session.turnActivity);
2105
+ }
2106
+ else if (event?.type === 'message_stop') {
2107
+ endClaudeAssistantResponse(session.turnActivity);
2108
+ }
2109
+ else if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
2110
+ // A tool is also a response boundary for speech. This fallback
2111
+ // keeps continual streaming correct on SDK streams that omit raw
2112
+ // message_start/message_stop events.
2113
+ endClaudeAssistantResponse(session.turnActivity);
2070
2114
  // Claimed per CALL, keyed by the SDK's tool_use id. The old id was
2071
2115
  // the turn id, so every call in a turn wrote over the same row.
2072
2116
  const commands = planClaudeToolCallStart(session.turnActivity, {
@@ -2104,8 +2148,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2104
2148
  stopVisibleWorkSignal();
2105
2149
  }
2106
2150
  writeTurn();
2107
- const blockIndex = typeof event.index === 'number' ? event.index : 'latest';
2108
- onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${blockIndex}`);
2151
+ onStreamDelta(event.delta.text, claimClaudeTextSegmentId(session.turnActivity, {
2152
+ turnId: session.currentTurnId ?? conversationId,
2153
+ index: event.index,
2154
+ }));
2109
2155
  }
2110
2156
  break;
2111
2157
  }
@@ -2521,6 +2567,15 @@ export async function main() {
2521
2567
  hostMode: true,
2522
2568
  rtdb,
2523
2569
  });
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
+ }
2524
2579
  let streamConnected = false;
2525
2580
  const hostAvailableExecutionModes = [
2526
2581
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -2832,6 +2887,7 @@ export async function main() {
2832
2887
  ownerName,
2833
2888
  membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
2834
2889
  groupContextMode: getGroupContextMode(input.conversationId, conversation),
2890
+ renderOptions: input.renderOptions,
2835
2891
  });
2836
2892
  }
2837
2893
  function closeSession(conversationId, options = {}) {
@@ -2932,10 +2988,12 @@ export async function main() {
2932
2988
  const session = sessions.get(conversationId);
2933
2989
  if (!session || session.pendingInputs.length === 0)
2934
2990
  return;
2935
- const before = session.pendingInputs.length;
2936
- session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
2937
- if (session.pendingInputs.length === before)
2991
+ const removed = session.pendingInputs.filter((input) => input.sourceMessageId === sourceMessageId);
2992
+ if (removed.length === 0)
2938
2993
  return;
2994
+ session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
2995
+ for (const input of removed)
2996
+ session.markInputCompleted(input);
2939
2997
  session.writeTurn();
2940
2998
  }
2941
2999
  function evictOldestIdle() {
@@ -3033,9 +3091,9 @@ export async function main() {
3033
3091
  runtimeInputManager,
3034
3092
  canRequestApproval: () => canRequestCanonApproval(conversationId),
3035
3093
  runtimeId,
3036
- recoveryBaseCwd: workingDir,
3037
3094
  ownerId,
3038
3095
  onInputCompleted: (sourceMessageId) => {
3096
+ recoveryCheckpointsFor(conversationId).settle(sourceMessageId);
3039
3097
  settleInboundMessageId(sourceMessageId, true);
3040
3098
  },
3041
3099
  publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
@@ -3093,13 +3151,15 @@ export async function main() {
3093
3151
  }
3094
3152
  const sender = m.senderName || m.senderId;
3095
3153
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
3096
- const content = renderInboundContent(m, materialized);
3154
+ const renderOptions = ownerBoundRenderOptions(isOwner);
3155
+ const content = renderInboundContent(m, materialized, renderOptions);
3097
3156
  const hydrated = await loadHydratedInboundContext({
3098
3157
  conversationId: input.conversationId,
3099
3158
  message: m,
3100
3159
  senderName: sender,
3101
3160
  isOwner,
3102
3161
  hydratedPage: input.hydratedPage,
3162
+ renderOptions,
3103
3163
  });
3104
3164
  const behavior = input.hydratedPage?.behavior ?? hydrated.behavior;
3105
3165
  const activeSelfContextId = hydrated.activeSelfContextId;
@@ -3109,6 +3169,7 @@ export async function main() {
3109
3169
  agentId,
3110
3170
  conversationId: input.conversationId,
3111
3171
  logPrefix: `[canon-host] [${input.conversationId.slice(0, 8)}]`,
3172
+ renderOptions,
3112
3173
  });
3113
3174
  const replyContext = replyMedia.replyContext;
3114
3175
  const participantContext = hydrated.participantContext;
@@ -3141,7 +3202,7 @@ export async function main() {
3141
3202
  return 'handled';
3142
3203
  }
3143
3204
  session.activeSelfContextId = activeSelfContextId;
3144
- const promptText = buildCanonPrompt({
3205
+ const basePromptText = buildCanonPrompt({
3145
3206
  content,
3146
3207
  conversationId: input.conversationId,
3147
3208
  participantContext,
@@ -3152,6 +3213,12 @@ export async function main() {
3152
3213
  replyContext,
3153
3214
  message: m,
3154
3215
  });
3216
+ const lifecycleContext = isOwner
3217
+ ? formatPendingContactLifecycleContext(takeLocalRuntimeContactLifecycleEvents(runtimeId, input.conversationId))
3218
+ : null;
3219
+ const promptText = lifecycleContext
3220
+ ? `${basePromptText}\n\n${lifecycleContext}`
3221
+ : basePromptText;
3155
3222
  const messageContent = await buildCanonUserContent({
3156
3223
  promptText,
3157
3224
  materialized: [...replyMedia.materialized, ...materialized],
@@ -3169,7 +3236,7 @@ export async function main() {
3169
3236
  senderId: m.senderId,
3170
3237
  senderName: m.senderName,
3171
3238
  }),
3172
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
3239
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes, ownerBoundReplyContactTarget(replyContext));
3173
3240
  return 'queued';
3174
3241
  }
3175
3242
  const acceptedInboundMessageIds = new Set();
@@ -3227,16 +3294,65 @@ export async function main() {
3227
3294
  workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
3228
3295
  })?.lastInboundMessageId ?? null;
3229
3296
  }
3297
+ function persistConversationRecoveryCursor(conversationId, messageId) {
3298
+ try {
3299
+ saveRuntimeSessionState(runtimeId, {
3300
+ conversationId,
3301
+ baseCwd: workingDir,
3302
+ workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
3303
+ lastInboundMessageId: messageId,
3304
+ });
3305
+ return true;
3306
+ }
3307
+ catch (error) {
3308
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
3309
+ return false;
3310
+ }
3311
+ }
3312
+ let startupRecoveryComplete = false;
3230
3313
  async function performMissedInboundRecovery() {
3314
+ const knownBeforeRefresh = new Set(knownConversationIds);
3315
+ await refreshKnownConversationIds(true);
3316
+ const conversationsDiscoveredWhileOffline = startupRecoveryComplete
3317
+ ? new Set([...knownConversationIds].filter((id) => !knownBeforeRefresh.has(id)))
3318
+ : 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
+ }
3231
3340
  for (const conversationId of knownConversationIds) {
3341
+ const recoveryCheckpoints = recoveryCheckpointsFor(conversationId);
3342
+ const recoveryBatch = recoveryCheckpoints.reserveBatch();
3232
3343
  try {
3233
3344
  const recovered = await collectMissedInboundMessages({
3234
3345
  fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
3235
3346
  cursor: persistedConversationCursor(conversationId),
3236
3347
  agentId,
3348
+ requireContiguousCursor: true,
3349
+ noCursorMode: conversationsDiscoveredWhileOffline.has(conversationId)
3350
+ ? 'bounded-window'
3351
+ : 'latest-only',
3237
3352
  });
3238
- if (recovered.mode === 'truncated-window') {
3239
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Recovery cursor was not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; replaying the bounded recent window`);
3353
+ recoveryBatch.commit(recovered.messages.map((message) => message.id), recovered.mode === 'incomplete-gap' ? recovered.recoveryCursor : null);
3354
+ if (recovered.mode === 'incomplete-gap') {
3355
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] recovery_incomplete_gap: cursor is missing; replaying ${recovered.messages.length} bounded inbound message(s) before advancing to ${recovered.recoveryCursor ?? 'no cursor'}`);
3240
3356
  }
3241
3357
  for (const message of recovered.messages) {
3242
3358
  if (!claimInboundMessageId(message.id))
@@ -3256,8 +3372,10 @@ export async function main() {
3256
3372
  }) === 'queued';
3257
3373
  }
3258
3374
  }
3259
- if (!queued)
3375
+ if (!queued) {
3376
+ recoveryCheckpoints.settle(message.id);
3260
3377
  settleInboundMessageId(message.id, true);
3378
+ }
3261
3379
  }
3262
3380
  catch (error) {
3263
3381
  settleInboundMessageId(message.id, false);
@@ -3269,21 +3387,14 @@ export async function main() {
3269
3387
  }
3270
3388
  }
3271
3389
  catch (error) {
3390
+ recoveryBatch.cancel();
3272
3391
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
3273
3392
  }
3274
3393
  }
3394
+ startupRecoveryComplete = true;
3275
3395
  }
3276
- let recoveryInFlight = null;
3277
- function recoverMissedInboundMessages() {
3278
- if (recoveryInFlight)
3279
- return recoveryInFlight;
3280
- recoveryInFlight = performMissedInboundRecovery().finally(() => {
3281
- recoveryInFlight = null;
3282
- });
3283
- return recoveryInFlight;
3284
- }
3285
- const shouldRecoverAfterStreamConnect = createReconnectRecoveryGate();
3286
- await recoverMissedInboundMessages();
3396
+ const reconnectRecovery = createReconnectRecoveryCoordinator(performMissedInboundRecovery);
3397
+ await reconnectRecovery.recoverNow();
3287
3398
  // ── Connect SSE stream for inbound Canon messages ──
3288
3399
  const stream = new CanonStream({
3289
3400
  apiKey,
@@ -3294,11 +3405,13 @@ export async function main() {
3294
3405
  const m = payload.message;
3295
3406
  if (m.senderId === agentId)
3296
3407
  return;
3408
+ const convoId = payload.conversationId;
3297
3409
  if (!claimInboundMessageId(m.id))
3298
3410
  return;
3299
- const convoId = payload.conversationId;
3411
+ recoveryCheckpointsFor(convoId).track(m.id);
3300
3412
  knownConversationIds.add(convoId);
3301
3413
  if (handleRuntimeReplyMessage(convoId, m)) {
3414
+ recoveryCheckpointsFor(convoId).settle(m.id);
3302
3415
  settleInboundMessageId(m.id, true);
3303
3416
  return;
3304
3417
  }
@@ -3306,6 +3419,7 @@ export async function main() {
3306
3419
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
3307
3420
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
3308
3421
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
3422
+ recoveryCheckpointsFor(convoId).settle(m.id);
3309
3423
  settleInboundMessageId(m.id, true);
3310
3424
  return;
3311
3425
  }
@@ -3322,7 +3436,8 @@ export async function main() {
3322
3436
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
3323
3437
  }
3324
3438
  }
3325
- const content = renderInboundContent(m, materialized);
3439
+ const renderOptions = ownerBoundRenderOptions(isOwner);
3440
+ const content = renderInboundContent(m, materialized, renderOptions);
3326
3441
  const hydrated = await loadHydratedInboundContext({
3327
3442
  conversationId: convoId,
3328
3443
  message: m,
@@ -3331,6 +3446,7 @@ export async function main() {
3331
3446
  activeSelfContextId: payload.activeSelfContextId,
3332
3447
  selfContexts: payload.selfContexts,
3333
3448
  provenance: payload.provenance,
3449
+ renderOptions,
3334
3450
  });
3335
3451
  const behavior = payload.behavior ?? hydrated.behavior;
3336
3452
  const activeSelfContextId = hydrated.activeSelfContextId;
@@ -3340,6 +3456,7 @@ export async function main() {
3340
3456
  agentId,
3341
3457
  conversationId: convoId,
3342
3458
  logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
3459
+ renderOptions,
3343
3460
  });
3344
3461
  const replyContext = replyMedia.replyContext;
3345
3462
  const participantContext = hydrated.participantContext;
@@ -3378,7 +3495,7 @@ export async function main() {
3378
3495
  return false;
3379
3496
  }
3380
3497
  session.activeSelfContextId = activeSelfContextId;
3381
- const promptText = buildCanonPrompt({
3498
+ const basePromptText = buildCanonPrompt({
3382
3499
  content,
3383
3500
  conversationId: convoId,
3384
3501
  participantContext,
@@ -3389,6 +3506,12 @@ export async function main() {
3389
3506
  replyContext,
3390
3507
  message: m,
3391
3508
  });
3509
+ const lifecycleContext = isOwner
3510
+ ? formatPendingContactLifecycleContext(takeLocalRuntimeContactLifecycleEvents(runtimeId, convoId))
3511
+ : null;
3512
+ const promptText = lifecycleContext
3513
+ ? `${basePromptText}\n\n${lifecycleContext}`
3514
+ : basePromptText;
3392
3515
  const messageContent = await buildCanonUserContent({
3393
3516
  promptText,
3394
3517
  materialized: [...replyMedia.materialized, ...materialized],
@@ -3406,11 +3529,13 @@ export async function main() {
3406
3529
  senderId: m.senderId,
3407
3530
  senderName: m.senderName,
3408
3531
  }),
3409
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
3532
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes, ownerBoundReplyContactTarget(replyContext));
3410
3533
  return true;
3411
3534
  })().then((queued) => {
3412
- if (!queued)
3535
+ if (!queued) {
3536
+ recoveryCheckpointsFor(convoId).settle(m.id);
3413
3537
  settleInboundMessageId(m.id, true);
3538
+ }
3414
3539
  }).catch((error) => {
3415
3540
  settleInboundMessageId(m.id, false);
3416
3541
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
@@ -3422,12 +3547,24 @@ export async function main() {
3422
3547
  onConversationUpdated: (payload) => {
3423
3548
  handleConversationUpdated(payload);
3424
3549
  },
3550
+ onContactRequestUpdated: (payload) => {
3551
+ if (payload.requesterId !== agentId || !payload.sourceConversationId)
3552
+ return;
3553
+ recordLocalRuntimeContactLifecycleEvent(runtimeId, payload);
3554
+ },
3555
+ onReplayExpired: () => {
3556
+ void reconnectRecovery.onReplayExpired().catch((error) => {
3557
+ console.error('[canon-host] Replay-expired recovery failed:', error);
3558
+ });
3559
+ },
3425
3560
  onConnected: () => {
3426
3561
  streamConnected = true;
3427
3562
  void publishRuntimeHeartbeat();
3428
- if (shouldRecoverAfterStreamConnect()) {
3429
- void recoverMissedInboundMessages();
3430
- }
3563
+ const recovery = reconnectRecovery.onConnected();
3564
+ if (recovery)
3565
+ void recovery.catch((error) => {
3566
+ console.error('[canon-host] Reconnect recovery failed:', error);
3567
+ });
3431
3568
  console.error('[canon-host] SSE connected');
3432
3569
  },
3433
3570
  onDisconnected: () => {
package/dist/register.js CHANGED
@@ -37,6 +37,7 @@ After approval, start it with CANON_AGENT=<profile> canon-claude --cwd /path/to/
37
37
  const OPTIONS = {
38
38
  moduleUrl: import.meta.url,
39
39
  clientType: 'claude-code',
40
+ sessionSetupPolicy: 'runtime_descriptor_required',
40
41
  cliName: 'canon-register',
41
42
  hostBinName: 'canon-claude',
42
43
  developerInfo: 'Claude Code plugin',
package/dist/server.js CHANGED
@@ -16,7 +16,8 @@ import { ApprovalHttpServer } from './approval-server.js';
16
16
  import { renderClaudeInboundContent } from './canon-user-content.js';
17
17
  import { runCli } from '@canonmsg/core';
18
18
  import { parseReplyArgs, parseSendMessageArgs, parseSetTypingArgs, } from './mcp-args.js';
19
- import { canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb, stampSendToTurnComplete, } from '@canonmsg/agent-tools';
19
+ import { OWNER_BOUND_CANON_COMMUNICATION_VERBS, canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb, stampSendToTurnComplete, } from '@canonmsg/agent-tools';
20
+ const OWNER_BOUND_COMMUNICATION_VERBS = new Set(OWNER_BOUND_CANON_COMMUNICATION_VERBS);
20
21
  const HELP = `canon-channel-server — Claude Code MCP channel server for Canon
21
22
 
22
23
  USAGE
@@ -174,7 +175,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
174
175
  // Canonical verbs — projections of canon.verbs.v1 (@canonmsg/agent-tools).
175
176
  // Channel mode owns no turn, so `no_reply` is an ack-only no-op here: the
176
177
  // standalone session has no final delivery for it to suppress.
177
- ...canonVerbToolDefinitions(),
178
+ ...canonVerbToolDefinitions().filter((definition) => !OWNER_BOUND_COMMUNICATION_VERBS.has(definition.name)),
178
179
  ],
179
180
  }));
180
181
  // ── Tool handlers ──────────────────────────────────────────────────────
@@ -314,6 +315,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
314
315
  }
315
316
  default: {
316
317
  const name = request.params.name;
318
+ if (OWNER_BOUND_COMMUNICATION_VERBS.has(name)) {
319
+ return toolArgumentError(`${name} requires an owner-authored Canon turn and is unavailable in standalone channel mode.`);
320
+ }
317
321
  if (isCanonToolVerb(name)) {
318
322
  const rawArgs = args && typeof args === 'object' ? { ...args } : {};
319
323
  const verbArgs = name === 'send_to' ? stampSendToTurnComplete(rawArgs) : rawArgs;
@@ -1,5 +1,6 @@
1
1
  import type { PermissionMode, SDKMessageOrigin, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
2
  import type { DeliveryIntent, ModelOption, RuntimeStreamingPayload, TurnLifecycleState, TurnOutputBlock, TurnOutputSnapshot, TurnVerbosity } from '@canonmsg/core';
3
+ import type { OwnerBoundCanonContactTarget } from '@canonmsg/agent-tools';
3
4
  import type { TurnArtifactRoutingDecision, TurnArtifactSnapshot } from '@canonmsg/coding-agent-host';
4
5
  export type ClaudeInputKind = 'seed' | 'canon';
5
6
  export type ClaudeArtifactRoutingMode = 'workspace-generated' | 'disabled';
@@ -23,6 +24,8 @@ export interface ClaudeInputEnvelope {
23
24
  turnKey: string;
24
25
  isOwnerTurn: boolean;
25
26
  requestingUserId: string | null;
27
+ /** Trusted contact-card target resolved from the owner message's reply. */
28
+ replyContactTarget?: OwnerBoundCanonContactTarget;
26
29
  }
27
30
  /**
28
31
  * The per-turn modes an inbound message resolves from its participant context,
@@ -102,6 +105,7 @@ export declare function createClaudeInputEnvelope(input: {
102
105
  artifactBaseline?: TurnArtifactSnapshot | null;
103
106
  isOwnerTurn?: boolean;
104
107
  requestingUserId?: string | null;
108
+ replyContactTarget?: OwnerBoundCanonContactTarget;
105
109
  }): ClaudeInputEnvelope;
106
110
  export declare function resolveClaudeTurnResponseRouting(turn: Pick<ClaudeInputEnvelope, 'kind' | 'isOwnerTurn' | 'requestingUserId'> | null | undefined, ownerId: string | null | undefined, ownerOnly?: boolean): {
107
111
  isOwnerTurn: boolean;
@@ -356,6 +360,10 @@ export interface ClaudeTurnActivityState {
356
360
  blockIdByToolUseId: Map<string, string>;
357
361
  /** This turn's plan block, once TodoWrite has produced one. */
358
362
  planBlockId: string | null;
363
+ /** Monotonic identity for assistant responses inside this turn. */
364
+ assistantResponseCount: number;
365
+ /** The response whose text deltas are currently being streamed. */
366
+ activeAssistantResponseKey: string | null;
359
367
  }
360
368
  /**
361
369
  * Whether an SDK message describes the agent's OWN work rather than a
@@ -367,6 +375,22 @@ export interface ClaudeTurnActivityState {
367
375
  export declare function isClaudeMainTurnMessage(parentToolUseId: unknown): boolean;
368
376
  export declare function createClaudeTurnActivityState(): ClaudeTurnActivityState;
369
377
  export declare function resetClaudeTurnActivityState(state: ClaudeTurnActivityState): void;
378
+ /** Open a fresh assistant response, whose content-block indexes start at zero. */
379
+ export declare function beginClaudeAssistantResponse(state: ClaudeTurnActivityState): string;
380
+ /** Close the response so a later text delta cannot reuse its block identity. */
381
+ export declare function endClaudeAssistantResponse(state: ClaudeTurnActivityState): void;
382
+ /**
383
+ * Stable id for one text block inside one assistant response.
384
+ *
385
+ * Anthropic restarts `content_block.index` for every assistant response. The
386
+ * response key is therefore required to distinguish text before a tool from
387
+ * text emitted after its result. Lazily opening a response keeps older SDK
388
+ * streams that omit `message_start` safe as well.
389
+ */
390
+ export declare function claimClaudeTextSegmentId(state: ClaudeTurnActivityState, input: {
391
+ turnId?: string | null;
392
+ index?: unknown;
393
+ }): string;
370
394
  export declare function claudeToolBlockId(toolUseId: string): string;
371
395
  export declare function claudePlanBlockId(turnId: string | null | undefined): string;
372
396
  /**
@@ -28,6 +28,9 @@ export function createClaudeInputEnvelope(input) {
28
28
  requestingUserId: input.kind === 'canon' && input.requestingUserId
29
29
  ? input.requestingUserId
30
30
  : null,
31
+ ...(input.kind === 'canon' && input.replyContactTarget
32
+ ? { replyContactTarget: input.replyContactTarget }
33
+ : {}),
31
34
  turnKey: input.kind === 'canon' && sourceMessageId
32
35
  ? `canon:${sourceMessageId}`
33
36
  : `${input.kind}:${fallbackId}`,
@@ -376,12 +379,42 @@ export function createClaudeTurnActivityState() {
376
379
  blockIdByIndex: new Map(),
377
380
  blockIdByToolUseId: new Map(),
378
381
  planBlockId: null,
382
+ assistantResponseCount: 0,
383
+ activeAssistantResponseKey: null,
379
384
  };
380
385
  }
381
386
  export function resetClaudeTurnActivityState(state) {
382
387
  state.blockIdByIndex.clear();
383
388
  state.blockIdByToolUseId.clear();
384
389
  state.planBlockId = null;
390
+ state.assistantResponseCount = 0;
391
+ state.activeAssistantResponseKey = null;
392
+ }
393
+ /** Open a fresh assistant response, whose content-block indexes start at zero. */
394
+ export function beginClaudeAssistantResponse(state) {
395
+ state.assistantResponseCount += 1;
396
+ state.activeAssistantResponseKey = `response-${state.assistantResponseCount}`;
397
+ return state.activeAssistantResponseKey;
398
+ }
399
+ /** Close the response so a later text delta cannot reuse its block identity. */
400
+ export function endClaudeAssistantResponse(state) {
401
+ state.activeAssistantResponseKey = null;
402
+ }
403
+ /**
404
+ * Stable id for one text block inside one assistant response.
405
+ *
406
+ * Anthropic restarts `content_block.index` for every assistant response. The
407
+ * response key is therefore required to distinguish text before a tool from
408
+ * text emitted after its result. Lazily opening a response keeps older SDK
409
+ * streams that omit `message_start` safe as well.
410
+ */
411
+ export function claimClaudeTextSegmentId(state, input) {
412
+ const responseKey = state.activeAssistantResponseKey
413
+ ?? beginClaudeAssistantResponse(state);
414
+ const index = typeof input.index === 'number' && Number.isInteger(input.index)
415
+ ? input.index
416
+ : 'latest';
417
+ return buildTrailBlockId('text', input.turnId ?? 'turn', responseKey, index);
385
418
  }
386
419
  export function claudeToolBlockId(toolUseId) {
387
420
  return buildTrailBlockId('tool', toolUseId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.31.3",
3
+ "version": "0.32.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",
@@ -31,10 +31,10 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "0.3.228",
34
- "@canonmsg/agent-sdk": "^8.6.0",
35
- "@canonmsg/agent-tools": "^0.5.3",
36
- "@canonmsg/coding-agent-host": "^0.5.0",
37
- "@canonmsg/core": "^10.4.0",
34
+ "@canonmsg/agent-sdk": "^8.9.0",
35
+ "@canonmsg/agent-tools": "^0.6.0",
36
+ "@canonmsg/coding-agent-host": "^0.6.0",
37
+ "@canonmsg/core": "^10.7.0",
38
38
  "@canonmsg/rich-cards": "^0.10.2",
39
39
  "@modelcontextprotocol/sdk": "^1.30.0"
40
40
  },