@canonmsg/claude-code-plugin 0.31.4 → 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.4",
4
+ "version": "0.32.0",
5
5
  "channels": [
6
6
  {
7
7
  "server": "canon-channel",
@@ -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,11 +27,11 @@ 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
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';
@@ -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({
@@ -2533,6 +2567,15 @@ export async function main() {
2533
2567
  hostMode: true,
2534
2568
  rtdb,
2535
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
+ }
2536
2579
  let streamConnected = false;
2537
2580
  const hostAvailableExecutionModes = [
2538
2581
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -2844,6 +2887,7 @@ export async function main() {
2844
2887
  ownerName,
2845
2888
  membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
2846
2889
  groupContextMode: getGroupContextMode(input.conversationId, conversation),
2890
+ renderOptions: input.renderOptions,
2847
2891
  });
2848
2892
  }
2849
2893
  function closeSession(conversationId, options = {}) {
@@ -2944,10 +2988,12 @@ export async function main() {
2944
2988
  const session = sessions.get(conversationId);
2945
2989
  if (!session || session.pendingInputs.length === 0)
2946
2990
  return;
2947
- const before = session.pendingInputs.length;
2948
- session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
2949
- if (session.pendingInputs.length === before)
2991
+ const removed = session.pendingInputs.filter((input) => input.sourceMessageId === sourceMessageId);
2992
+ if (removed.length === 0)
2950
2993
  return;
2994
+ session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
2995
+ for (const input of removed)
2996
+ session.markInputCompleted(input);
2951
2997
  session.writeTurn();
2952
2998
  }
2953
2999
  function evictOldestIdle() {
@@ -3045,9 +3091,9 @@ export async function main() {
3045
3091
  runtimeInputManager,
3046
3092
  canRequestApproval: () => canRequestCanonApproval(conversationId),
3047
3093
  runtimeId,
3048
- recoveryBaseCwd: workingDir,
3049
3094
  ownerId,
3050
3095
  onInputCompleted: (sourceMessageId) => {
3096
+ recoveryCheckpointsFor(conversationId).settle(sourceMessageId);
3051
3097
  settleInboundMessageId(sourceMessageId, true);
3052
3098
  },
3053
3099
  publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
@@ -3105,13 +3151,15 @@ export async function main() {
3105
3151
  }
3106
3152
  const sender = m.senderName || m.senderId;
3107
3153
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
3108
- const content = renderInboundContent(m, materialized);
3154
+ const renderOptions = ownerBoundRenderOptions(isOwner);
3155
+ const content = renderInboundContent(m, materialized, renderOptions);
3109
3156
  const hydrated = await loadHydratedInboundContext({
3110
3157
  conversationId: input.conversationId,
3111
3158
  message: m,
3112
3159
  senderName: sender,
3113
3160
  isOwner,
3114
3161
  hydratedPage: input.hydratedPage,
3162
+ renderOptions,
3115
3163
  });
3116
3164
  const behavior = input.hydratedPage?.behavior ?? hydrated.behavior;
3117
3165
  const activeSelfContextId = hydrated.activeSelfContextId;
@@ -3121,6 +3169,7 @@ export async function main() {
3121
3169
  agentId,
3122
3170
  conversationId: input.conversationId,
3123
3171
  logPrefix: `[canon-host] [${input.conversationId.slice(0, 8)}]`,
3172
+ renderOptions,
3124
3173
  });
3125
3174
  const replyContext = replyMedia.replyContext;
3126
3175
  const participantContext = hydrated.participantContext;
@@ -3153,7 +3202,7 @@ export async function main() {
3153
3202
  return 'handled';
3154
3203
  }
3155
3204
  session.activeSelfContextId = activeSelfContextId;
3156
- const promptText = buildCanonPrompt({
3205
+ const basePromptText = buildCanonPrompt({
3157
3206
  content,
3158
3207
  conversationId: input.conversationId,
3159
3208
  participantContext,
@@ -3164,6 +3213,12 @@ export async function main() {
3164
3213
  replyContext,
3165
3214
  message: m,
3166
3215
  });
3216
+ const lifecycleContext = isOwner
3217
+ ? formatPendingContactLifecycleContext(takeLocalRuntimeContactLifecycleEvents(runtimeId, input.conversationId))
3218
+ : null;
3219
+ const promptText = lifecycleContext
3220
+ ? `${basePromptText}\n\n${lifecycleContext}`
3221
+ : basePromptText;
3167
3222
  const messageContent = await buildCanonUserContent({
3168
3223
  promptText,
3169
3224
  materialized: [...replyMedia.materialized, ...materialized],
@@ -3181,7 +3236,7 @@ export async function main() {
3181
3236
  senderId: m.senderId,
3182
3237
  senderName: m.senderName,
3183
3238
  }),
3184
- }, 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));
3185
3240
  return 'queued';
3186
3241
  }
3187
3242
  const acceptedInboundMessageIds = new Set();
@@ -3239,16 +3294,65 @@ export async function main() {
3239
3294
  workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
3240
3295
  })?.lastInboundMessageId ?? null;
3241
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;
3242
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
+ }
3243
3340
  for (const conversationId of knownConversationIds) {
3341
+ const recoveryCheckpoints = recoveryCheckpointsFor(conversationId);
3342
+ const recoveryBatch = recoveryCheckpoints.reserveBatch();
3244
3343
  try {
3245
3344
  const recovered = await collectMissedInboundMessages({
3246
3345
  fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
3247
3346
  cursor: persistedConversationCursor(conversationId),
3248
3347
  agentId,
3348
+ requireContiguousCursor: true,
3349
+ noCursorMode: conversationsDiscoveredWhileOffline.has(conversationId)
3350
+ ? 'bounded-window'
3351
+ : 'latest-only',
3249
3352
  });
3250
- if (recovered.mode === 'truncated-window') {
3251
- 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'}`);
3252
3356
  }
3253
3357
  for (const message of recovered.messages) {
3254
3358
  if (!claimInboundMessageId(message.id))
@@ -3268,8 +3372,10 @@ export async function main() {
3268
3372
  }) === 'queued';
3269
3373
  }
3270
3374
  }
3271
- if (!queued)
3375
+ if (!queued) {
3376
+ recoveryCheckpoints.settle(message.id);
3272
3377
  settleInboundMessageId(message.id, true);
3378
+ }
3273
3379
  }
3274
3380
  catch (error) {
3275
3381
  settleInboundMessageId(message.id, false);
@@ -3281,21 +3387,14 @@ export async function main() {
3281
3387
  }
3282
3388
  }
3283
3389
  catch (error) {
3390
+ recoveryBatch.cancel();
3284
3391
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
3285
3392
  }
3286
3393
  }
3394
+ startupRecoveryComplete = true;
3287
3395
  }
3288
- let recoveryInFlight = null;
3289
- function recoverMissedInboundMessages() {
3290
- if (recoveryInFlight)
3291
- return recoveryInFlight;
3292
- recoveryInFlight = performMissedInboundRecovery().finally(() => {
3293
- recoveryInFlight = null;
3294
- });
3295
- return recoveryInFlight;
3296
- }
3297
- const shouldRecoverAfterStreamConnect = createReconnectRecoveryGate();
3298
- await recoverMissedInboundMessages();
3396
+ const reconnectRecovery = createReconnectRecoveryCoordinator(performMissedInboundRecovery);
3397
+ await reconnectRecovery.recoverNow();
3299
3398
  // ── Connect SSE stream for inbound Canon messages ──
3300
3399
  const stream = new CanonStream({
3301
3400
  apiKey,
@@ -3306,11 +3405,13 @@ export async function main() {
3306
3405
  const m = payload.message;
3307
3406
  if (m.senderId === agentId)
3308
3407
  return;
3408
+ const convoId = payload.conversationId;
3309
3409
  if (!claimInboundMessageId(m.id))
3310
3410
  return;
3311
- const convoId = payload.conversationId;
3411
+ recoveryCheckpointsFor(convoId).track(m.id);
3312
3412
  knownConversationIds.add(convoId);
3313
3413
  if (handleRuntimeReplyMessage(convoId, m)) {
3414
+ recoveryCheckpointsFor(convoId).settle(m.id);
3314
3415
  settleInboundMessageId(m.id, true);
3315
3416
  return;
3316
3417
  }
@@ -3318,6 +3419,7 @@ export async function main() {
3318
3419
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
3319
3420
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
3320
3421
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
3422
+ recoveryCheckpointsFor(convoId).settle(m.id);
3321
3423
  settleInboundMessageId(m.id, true);
3322
3424
  return;
3323
3425
  }
@@ -3334,7 +3436,8 @@ export async function main() {
3334
3436
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
3335
3437
  }
3336
3438
  }
3337
- const content = renderInboundContent(m, materialized);
3439
+ const renderOptions = ownerBoundRenderOptions(isOwner);
3440
+ const content = renderInboundContent(m, materialized, renderOptions);
3338
3441
  const hydrated = await loadHydratedInboundContext({
3339
3442
  conversationId: convoId,
3340
3443
  message: m,
@@ -3343,6 +3446,7 @@ export async function main() {
3343
3446
  activeSelfContextId: payload.activeSelfContextId,
3344
3447
  selfContexts: payload.selfContexts,
3345
3448
  provenance: payload.provenance,
3449
+ renderOptions,
3346
3450
  });
3347
3451
  const behavior = payload.behavior ?? hydrated.behavior;
3348
3452
  const activeSelfContextId = hydrated.activeSelfContextId;
@@ -3352,6 +3456,7 @@ export async function main() {
3352
3456
  agentId,
3353
3457
  conversationId: convoId,
3354
3458
  logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
3459
+ renderOptions,
3355
3460
  });
3356
3461
  const replyContext = replyMedia.replyContext;
3357
3462
  const participantContext = hydrated.participantContext;
@@ -3390,7 +3495,7 @@ export async function main() {
3390
3495
  return false;
3391
3496
  }
3392
3497
  session.activeSelfContextId = activeSelfContextId;
3393
- const promptText = buildCanonPrompt({
3498
+ const basePromptText = buildCanonPrompt({
3394
3499
  content,
3395
3500
  conversationId: convoId,
3396
3501
  participantContext,
@@ -3401,6 +3506,12 @@ export async function main() {
3401
3506
  replyContext,
3402
3507
  message: m,
3403
3508
  });
3509
+ const lifecycleContext = isOwner
3510
+ ? formatPendingContactLifecycleContext(takeLocalRuntimeContactLifecycleEvents(runtimeId, convoId))
3511
+ : null;
3512
+ const promptText = lifecycleContext
3513
+ ? `${basePromptText}\n\n${lifecycleContext}`
3514
+ : basePromptText;
3404
3515
  const messageContent = await buildCanonUserContent({
3405
3516
  promptText,
3406
3517
  materialized: [...replyMedia.materialized, ...materialized],
@@ -3418,11 +3529,13 @@ export async function main() {
3418
3529
  senderId: m.senderId,
3419
3530
  senderName: m.senderName,
3420
3531
  }),
3421
- }, 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));
3422
3533
  return true;
3423
3534
  })().then((queued) => {
3424
- if (!queued)
3535
+ if (!queued) {
3536
+ recoveryCheckpointsFor(convoId).settle(m.id);
3425
3537
  settleInboundMessageId(m.id, true);
3538
+ }
3426
3539
  }).catch((error) => {
3427
3540
  settleInboundMessageId(m.id, false);
3428
3541
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
@@ -3434,12 +3547,24 @@ export async function main() {
3434
3547
  onConversationUpdated: (payload) => {
3435
3548
  handleConversationUpdated(payload);
3436
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
+ },
3437
3560
  onConnected: () => {
3438
3561
  streamConnected = true;
3439
3562
  void publishRuntimeHeartbeat();
3440
- if (shouldRecoverAfterStreamConnect()) {
3441
- void recoverMissedInboundMessages();
3442
- }
3563
+ const recovery = reconnectRecovery.onConnected();
3564
+ if (recovery)
3565
+ void recovery.catch((error) => {
3566
+ console.error('[canon-host] Reconnect recovery failed:', error);
3567
+ });
3443
3568
  console.error('[canon-host] SSE connected');
3444
3569
  },
3445
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;
@@ -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}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.31.4",
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
  },