@canonmsg/claude-code-plugin 0.31.4 → 0.33.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.33.0",
5
5
  "channels": [
6
6
  {
7
7
  "server": "canon-channel",
package/dist/host.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { type PermissionResult, type SDKControlReloadPluginsResponse } from '@anthropic-ai/claude-agent-sdk';
3
- import { type CanonRuntimeCommandDescriptor, type HostInboundParticipantContext as InboundParticipantContext, type CanonReplyContext, type MessageCreatedPayload, type ResolvedAgentBehaviorPolicy, type TurnVerbosityConfig } from '@canonmsg/core';
3
+ import { type RecoveryCheckpointTracker } from '@canonmsg/coding-agent-host';
4
+ import { type CanonRuntimeCommandDescriptor, type ExecutionEnvironmentMode, 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
6
7
  * per-conversation-type default".
@@ -24,6 +25,10 @@ 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 resolveSessionExecutionMode(config: {
29
+ executionMode?: ExecutionEnvironmentMode;
30
+ } | null | undefined, defaultExecutionMode?: ExecutionEnvironmentMode): ExecutionEnvironmentMode;
31
+ export declare function createClaudeRecoveryCheckpointTracker(persist: (messageId: string) => boolean): RecoveryCheckpointTracker;
27
32
  export declare const NO_REPLY_TOOL_NAME: string;
28
33
  export declare function buildCanonPrompt(input: {
29
34
  content: string;
package/dist/host.js CHANGED
@@ -27,7 +27,7 @@ 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
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';
33
33
  import { runCli } from '@canonmsg/core';
@@ -53,6 +53,8 @@ COMMON FLAGS
53
53
  --cwd <path> Project directory to run Claude Code from
54
54
  --workspace <path> Additional project to expose in Canon
55
55
  --workspace-root <path> Discover projects under an approved root
56
+ --default-execution-mode <worktree|locked>
57
+ Default for new Canon conversations
56
58
  --runtime-visibility <normal|minimal|full>
57
59
  Detail visibility preset
58
60
  --show-runtime-detail <field>
@@ -271,6 +273,7 @@ function buildClaudeRuntimeDescriptor(input) {
271
273
  workspaces: input.workspaces,
272
274
  workspaceRoots: input.workspaceRoots,
273
275
  executionModes: input.executionModes,
276
+ defaultExecutionMode: input.defaultExecutionMode,
274
277
  permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
275
278
  defaultPermissionMode: 'default',
276
279
  effortOptions: [...EFFORT_OPTIONS],
@@ -431,10 +434,15 @@ async function loadSessionConfig(conversationId, agentId, rtdb) {
431
434
  ultracode: parseClaudeUltracodeMode(config?.runtimeControlValues?.ultracode),
432
435
  };
433
436
  }
434
- function resolveSessionExecutionMode(config) {
435
- if (config?.executionMode)
436
- return config.executionMode;
437
- throw new ExecutionEnvironmentError('Session config is missing an execution mode.', 'Choose Isolated worktree or Use shared project before starting this coding session.');
437
+ export function resolveSessionExecutionMode(config, defaultExecutionMode = 'worktree') {
438
+ return config?.executionMode ?? defaultExecutionMode;
439
+ }
440
+ function resolveConfiguredDefaultExecutionMode(value) {
441
+ if (value == null || value === '')
442
+ return 'worktree';
443
+ if (value === 'worktree' || value === 'locked')
444
+ return value;
445
+ throw new Error('--default-execution-mode must be worktree or locked');
438
446
  }
439
447
  function resolveWorkspaceCwd(config) {
440
448
  return resolveHostWorkspaceCwd({
@@ -728,6 +736,9 @@ const FINAL_DELIVERY_RETRY_MS = 30_000;
728
736
  // without delaying anything that was going to fail anyway.
729
737
  const MAX_FINAL_DELIVERY_RETRIES = 2;
730
738
  const CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID = 'claude-recovery-cursor-v1';
739
+ export function createClaudeRecoveryCheckpointTracker(persist) {
740
+ return createRecoveryCheckpointTracker(persist);
741
+ }
731
742
  const CLAUDE_RUNTIME_CAPABILITIES = {
732
743
  ...DEFAULT_RUNTIME_CAPABILITIES,
733
744
  supportsInterrupt: true,
@@ -979,7 +990,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
979
990
  const turnKey = session.activeInput?.turnKey;
980
991
  if (turnKey)
981
992
  session.silencedTurnKeys.add(turnKey);
982
- }),
993
+ }, { communication: config.communicationEnabled !== false }),
983
994
  },
984
995
  },
985
996
  ...(claudeCliPath ? { pathToClaudeCodeExecutable: claudeCliPath } : {}),
@@ -1260,19 +1271,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1260
1271
  function markInputCompleted(input) {
1261
1272
  if (input?.kind !== 'canon' || !input.sourceMessageId)
1262
1273
  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
1274
  config.onInputCompleted?.(input.sourceMessageId);
1277
1275
  }
1278
1276
  /**
@@ -2396,11 +2394,13 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2396
2394
  availableModels: modelList,
2397
2395
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
2398
2396
  availableExecutionModes: config.availableExecutionModes,
2397
+ defaultExecutionMode: config.defaultExecutionMode,
2399
2398
  runtimeDescriptor: buildClaudeRuntimeDescriptor({
2400
2399
  models: modelList,
2401
2400
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
2402
2401
  workspaceRoots: workspaceRootMetadata,
2403
2402
  executionModes: config.availableExecutionModes,
2403
+ defaultExecutionMode: config.defaultExecutionMode,
2404
2404
  presentation: config.runtimePresentation,
2405
2405
  }),
2406
2406
  });
@@ -2430,6 +2430,7 @@ export async function main() {
2430
2430
  cwd: { type: 'string' },
2431
2431
  workspace: { type: 'string', multiple: true },
2432
2432
  'workspace-root': { type: 'string', multiple: true },
2433
+ 'default-execution-mode': { type: 'string' },
2433
2434
  'runtime-visibility': { type: 'string' },
2434
2435
  'show-runtime-detail': { type: 'string', multiple: true },
2435
2436
  'hide-runtime-detail': { type: 'string', multiple: true },
@@ -2442,6 +2443,7 @@ export async function main() {
2442
2443
  env: process.env.CANON_TURN_VERBOSITY,
2443
2444
  onWarning: (message) => console.error(`[canon-host] ${message}`),
2444
2445
  });
2446
+ const defaultExecutionMode = resolveConfiguredDefaultExecutionMode(args['default-execution-mode']);
2445
2447
  workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
2446
2448
  const configuredWorkspaces = (args.workspace ?? []).filter((value) => typeof value === 'string');
2447
2449
  const configuredWorkspaceRoots = (args['workspace-root'] ?? []).filter((value) => typeof value === 'string');
@@ -2482,11 +2484,13 @@ export async function main() {
2482
2484
  let agentId;
2483
2485
  let ownerId = null;
2484
2486
  let ownerName = null;
2487
+ let communicationEnabled = true;
2485
2488
  try {
2486
2489
  const ctx = await client.getAgentMe();
2487
2490
  agentId = ctx.agentId;
2488
2491
  ownerId = ctx.ownerId;
2489
2492
  ownerName = ctx.ownerName;
2493
+ communicationEnabled = ctx.outboundPolicy !== 'closed';
2490
2494
  console.error(`[canon-host] Connected as ${ctx.displayName || agentId}`);
2491
2495
  }
2492
2496
  catch {
@@ -2544,6 +2548,7 @@ export async function main() {
2544
2548
  hide: stringArgs(args['hide-runtime-detail']),
2545
2549
  });
2546
2550
  let runtimeDescriptor = {
2551
+ defaultExecutionMode,
2547
2552
  defaultWorkspaceId: workspaceOptions[0]?.id,
2548
2553
  defaultPermissionMode: 'default',
2549
2554
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
@@ -2554,6 +2559,7 @@ export async function main() {
2554
2559
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
2555
2560
  workspaceRoots: workspaceRootMetadata,
2556
2561
  executionModes: hostAvailableExecutionModes,
2562
+ defaultExecutionMode,
2557
2563
  presentation: runtimePresentation,
2558
2564
  }),
2559
2565
  };
@@ -2632,6 +2638,7 @@ export async function main() {
2632
2638
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
2633
2639
  workspaceRoots: workspaceRootMetadata,
2634
2640
  executionModes: hostAvailableExecutionModes,
2641
+ defaultExecutionMode,
2635
2642
  presentation: runtimePresentation,
2636
2643
  }),
2637
2644
  };
@@ -2666,6 +2673,7 @@ export async function main() {
2666
2673
  try {
2667
2674
  runtimeModels = await detectRuntimeModels(workingDir);
2668
2675
  runtimeDescriptor = {
2676
+ defaultExecutionMode,
2669
2677
  defaultWorkspaceId: workspaceOptions[0]?.id,
2670
2678
  defaultModel: runtimeModels[0]?.value,
2671
2679
  defaultPermissionMode: 'default',
@@ -2678,12 +2686,14 @@ export async function main() {
2678
2686
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
2679
2687
  workspaceRoots: workspaceRootMetadata,
2680
2688
  executionModes: hostAvailableExecutionModes,
2689
+ defaultExecutionMode,
2681
2690
  presentation: runtimePresentation,
2682
2691
  }),
2683
2692
  };
2684
2693
  }
2685
2694
  catch {
2686
2695
  runtimeDescriptor = {
2696
+ defaultExecutionMode,
2687
2697
  defaultWorkspaceId: workspaceOptions[0]?.id,
2688
2698
  defaultPermissionMode: 'default',
2689
2699
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
@@ -2694,6 +2704,7 @@ export async function main() {
2694
2704
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
2695
2705
  workspaceRoots: workspaceRootMetadata,
2696
2706
  executionModes: hostAvailableExecutionModes,
2707
+ defaultExecutionMode,
2697
2708
  presentation: runtimePresentation,
2698
2709
  }),
2699
2710
  };
@@ -2716,6 +2727,15 @@ export async function main() {
2716
2727
  }
2717
2728
  // ── Session manager ──
2718
2729
  const sessions = new Map();
2730
+ const recoveryCheckpointTrackers = new Map();
2731
+ function recoveryCheckpointsFor(conversationId) {
2732
+ let tracker = recoveryCheckpointTrackers.get(conversationId);
2733
+ if (!tracker) {
2734
+ tracker = createClaudeRecoveryCheckpointTracker((messageId) => persistConversationRecoveryCursor(conversationId, messageId));
2735
+ recoveryCheckpointTrackers.set(conversationId, tracker);
2736
+ }
2737
+ return tracker;
2738
+ }
2719
2739
  const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
2720
2740
  // Interactive runtime input (AskUserQuestion) works with or without an owner,
2721
2741
  // so its poll engine is always present. Create + poll fold onto the built-in
@@ -2944,10 +2964,12 @@ export async function main() {
2944
2964
  const session = sessions.get(conversationId);
2945
2965
  if (!session || session.pendingInputs.length === 0)
2946
2966
  return;
2947
- const before = session.pendingInputs.length;
2948
- session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
2949
- if (session.pendingInputs.length === before)
2967
+ const removed = session.pendingInputs.filter((input) => input.sourceMessageId === sourceMessageId);
2968
+ if (removed.length === 0)
2950
2969
  return;
2970
+ session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
2971
+ for (const input of removed)
2972
+ session.markInputCompleted(input);
2951
2973
  session.writeTurn();
2952
2974
  }
2953
2975
  function evictOldestIdle() {
@@ -2979,7 +3001,7 @@ export async function main() {
2979
3001
  }
2980
3002
  const creation = (async () => {
2981
3003
  const config = await loadSessionConfig(conversationId, agentId, rtdb);
2982
- const sessionExecutionMode = resolveSessionExecutionMode(config);
3004
+ const sessionExecutionMode = resolveSessionExecutionMode(config, defaultExecutionMode);
2983
3005
  const workspaceCwd = resolveWorkspaceCwd(config);
2984
3006
  const environment = prepareConversationEnvironment({
2985
3007
  agentId,
@@ -3040,14 +3062,16 @@ export async function main() {
3040
3062
  ultracode: config?.ultracode,
3041
3063
  availableModels: runtimeModels,
3042
3064
  availableExecutionModes: hostAvailableExecutionModes,
3065
+ defaultExecutionMode,
3043
3066
  runtimePresentation,
3044
3067
  approvalManager,
3045
3068
  runtimeInputManager,
3046
3069
  canRequestApproval: () => canRequestCanonApproval(conversationId),
3047
3070
  runtimeId,
3048
- recoveryBaseCwd: workingDir,
3049
3071
  ownerId,
3072
+ communicationEnabled,
3050
3073
  onInputCompleted: (sourceMessageId) => {
3074
+ recoveryCheckpointsFor(conversationId).settle(sourceMessageId);
3051
3075
  settleInboundMessageId(sourceMessageId, true);
3052
3076
  },
3053
3077
  publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
@@ -3239,16 +3263,44 @@ export async function main() {
3239
3263
  workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
3240
3264
  })?.lastInboundMessageId ?? null;
3241
3265
  }
3266
+ function persistConversationRecoveryCursor(conversationId, messageId) {
3267
+ try {
3268
+ saveRuntimeSessionState(runtimeId, {
3269
+ conversationId,
3270
+ baseCwd: workingDir,
3271
+ workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
3272
+ lastInboundMessageId: messageId,
3273
+ });
3274
+ return true;
3275
+ }
3276
+ catch (error) {
3277
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
3278
+ return false;
3279
+ }
3280
+ }
3281
+ let startupRecoveryComplete = false;
3242
3282
  async function performMissedInboundRecovery() {
3283
+ const knownBeforeRefresh = new Set(knownConversationIds);
3284
+ await refreshKnownConversationIds(true);
3285
+ const conversationsDiscoveredWhileOffline = startupRecoveryComplete
3286
+ ? new Set([...knownConversationIds].filter((id) => !knownBeforeRefresh.has(id)))
3287
+ : new Set();
3243
3288
  for (const conversationId of knownConversationIds) {
3289
+ const recoveryCheckpoints = recoveryCheckpointsFor(conversationId);
3290
+ const recoveryBatch = recoveryCheckpoints.reserveBatch();
3244
3291
  try {
3245
3292
  const recovered = await collectMissedInboundMessages({
3246
3293
  fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
3247
3294
  cursor: persistedConversationCursor(conversationId),
3248
3295
  agentId,
3296
+ requireContiguousCursor: true,
3297
+ noCursorMode: conversationsDiscoveredWhileOffline.has(conversationId)
3298
+ ? 'bounded-window'
3299
+ : 'latest-only',
3249
3300
  });
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`);
3301
+ recoveryBatch.commit(recovered.messages.map((message) => message.id), recovered.mode === 'incomplete-gap' ? recovered.recoveryCursor : null);
3302
+ if (recovered.mode === 'incomplete-gap') {
3303
+ 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
3304
  }
3253
3305
  for (const message of recovered.messages) {
3254
3306
  if (!claimInboundMessageId(message.id))
@@ -3268,8 +3320,10 @@ export async function main() {
3268
3320
  }) === 'queued';
3269
3321
  }
3270
3322
  }
3271
- if (!queued)
3323
+ if (!queued) {
3324
+ recoveryCheckpoints.settle(message.id);
3272
3325
  settleInboundMessageId(message.id, true);
3326
+ }
3273
3327
  }
3274
3328
  catch (error) {
3275
3329
  settleInboundMessageId(message.id, false);
@@ -3281,21 +3335,14 @@ export async function main() {
3281
3335
  }
3282
3336
  }
3283
3337
  catch (error) {
3338
+ recoveryBatch.cancel();
3284
3339
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
3285
3340
  }
3286
3341
  }
3342
+ startupRecoveryComplete = true;
3287
3343
  }
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();
3344
+ const reconnectRecovery = createReconnectRecoveryCoordinator(performMissedInboundRecovery);
3345
+ await reconnectRecovery.recoverNow();
3299
3346
  // ── Connect SSE stream for inbound Canon messages ──
3300
3347
  const stream = new CanonStream({
3301
3348
  apiKey,
@@ -3306,11 +3353,13 @@ export async function main() {
3306
3353
  const m = payload.message;
3307
3354
  if (m.senderId === agentId)
3308
3355
  return;
3356
+ const convoId = payload.conversationId;
3309
3357
  if (!claimInboundMessageId(m.id))
3310
3358
  return;
3311
- const convoId = payload.conversationId;
3359
+ recoveryCheckpointsFor(convoId).track(m.id);
3312
3360
  knownConversationIds.add(convoId);
3313
3361
  if (handleRuntimeReplyMessage(convoId, m)) {
3362
+ recoveryCheckpointsFor(convoId).settle(m.id);
3314
3363
  settleInboundMessageId(m.id, true);
3315
3364
  return;
3316
3365
  }
@@ -3318,6 +3367,7 @@ export async function main() {
3318
3367
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
3319
3368
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
3320
3369
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
3370
+ recoveryCheckpointsFor(convoId).settle(m.id);
3321
3371
  settleInboundMessageId(m.id, true);
3322
3372
  return;
3323
3373
  }
@@ -3421,8 +3471,10 @@ export async function main() {
3421
3471
  }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
3422
3472
  return true;
3423
3473
  })().then((queued) => {
3424
- if (!queued)
3474
+ if (!queued) {
3475
+ recoveryCheckpointsFor(convoId).settle(m.id);
3425
3476
  settleInboundMessageId(m.id, true);
3477
+ }
3426
3478
  }).catch((error) => {
3427
3479
  settleInboundMessageId(m.id, false);
3428
3480
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
@@ -3434,11 +3486,19 @@ export async function main() {
3434
3486
  onConversationUpdated: (payload) => {
3435
3487
  handleConversationUpdated(payload);
3436
3488
  },
3489
+ onReplayExpired: () => {
3490
+ void reconnectRecovery.onReplayExpired().catch((error) => {
3491
+ console.error('[canon-host] Replay-expiry recovery failed:', error instanceof Error ? error.message : error);
3492
+ });
3493
+ },
3437
3494
  onConnected: () => {
3438
3495
  streamConnected = true;
3439
3496
  void publishRuntimeHeartbeat();
3440
- if (shouldRecoverAfterStreamConnect()) {
3441
- void recoverMissedInboundMessages();
3497
+ const recovery = reconnectRecovery.onConnected();
3498
+ if (recovery) {
3499
+ void recovery.catch((error) => {
3500
+ console.error('[canon-host] Reconnect recovery failed:', error instanceof Error ? error.message : error);
3501
+ });
3442
3502
  }
3443
3503
  console.error('[canon-host] SSE connected');
3444
3504
  },
package/dist/server.js CHANGED
@@ -16,7 +16,7 @@ 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 { canonVerbToolDefinitions, createCanonCommunicationBinding, executeCanonVerbTool, isCanonToolVerb, } from '@canonmsg/agent-tools';
20
20
  const HELP = `canon-channel-server — Claude Code MCP channel server for Canon
21
21
 
22
22
  USAGE
@@ -45,6 +45,7 @@ const conversationCache = new Map();
45
45
  const TURN_COMPLETE_METADATA = {
46
46
  turnSemantics: 'turn_complete',
47
47
  };
48
+ const communicationBinding = createCanonCommunicationBinding();
48
49
  /** Last owner-visible conversation to route approval cards into. */
49
50
  let approvalConversationId = null;
50
51
  /** Whether the message that most recently triggered work came from the owner. */
@@ -175,6 +176,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
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
178
  ...canonVerbToolDefinitions(),
179
+ ...(agentContext?.outboundPolicy === 'closed' ? [] : communicationBinding.tools),
178
180
  ],
179
181
  }));
180
182
  // ── Tool handlers ──────────────────────────────────────────────────────
@@ -314,10 +316,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
314
316
  }
315
317
  default: {
316
318
  const name = request.params.name;
319
+ if (communicationBinding.isToolName(name)) {
320
+ const result = await communicationBinding.execute(client, name, args);
321
+ return {
322
+ content: result.content.map((item) => ({ type: 'text', text: item.text })),
323
+ ...(result.isError ? { isError: true } : {}),
324
+ };
325
+ }
317
326
  if (isCanonToolVerb(name)) {
318
327
  const rawArgs = args && typeof args === 'object' ? { ...args } : {};
319
- const verbArgs = name === 'send_to' ? stampSendToTurnComplete(rawArgs) : rawArgs;
320
- const verbResult = await executeCanonVerbTool(client, name, verbArgs);
328
+ const verbResult = await executeCanonVerbTool(client, name, rawArgs);
321
329
  return {
322
330
  content: verbResult.content.map((item) => ({ type: 'text', text: item.text })),
323
331
  ...(verbResult.isError ? { isError: true } : {}),
@@ -37,13 +37,9 @@ function normalizeToolName(toolName) {
37
37
  function isCanonOutboundTool(normalizedToolName) {
38
38
  return normalizedToolName.endsWith('__reply')
39
39
  || normalizedToolName.endsWith('__send_message')
40
- || normalizedToolName.endsWith('__send_contextual_message')
41
- || normalizedToolName.endsWith('__send_to')
42
40
  || normalizedToolName.endsWith('__share_contact')
43
41
  || normalizedToolName === 'reply'
44
42
  || normalizedToolName === 'send_message'
45
- || normalizedToolName === 'send_contextual_message'
46
- || normalizedToolName === 'send_to'
47
43
  || normalizedToolName === 'share_contact';
48
44
  }
49
45
  /**
@@ -79,7 +75,7 @@ const CANON_INTERACTION_LIFECYCLE_VERBS = new Set([
79
75
  * Verbs a NON-owner conversation member may trigger: same-conversation HITL
80
76
  * and display surfaces, whose responder routing and owner-only escalation
81
77
  * (secret/sudo, session rules) are enforced server-side. Cross-conversation
82
- * verbs (send_to, share_contact) and the reads (contacts, conversation
78
+ * cross-conversation verbs (share_contact) and the reads (contacts, conversation
83
79
  * lists — they expose the agent's other relationships to this conversation)
84
80
  * stay owner-only until the provenance trigger-token primitive lands.
85
81
  */
@@ -257,7 +253,7 @@ export async function decideClaudeToolPermissionForMode(input) {
257
253
  }
258
254
  // HITL + read verbs are allowed without an approval card: the HITL verbs
259
255
  // themselves render the human-facing card (recursion otherwise), and the
260
- // reads are side-effect free. Outbound verbs (send_to, share_contact)
256
+ // reads are side-effect free. Outbound verbs (share_contact)
261
257
  // fall through to the mode policy — in native-approval mode they gate
262
258
  // like any other side effect; in default mode owner turns allow.
263
259
  if (CANON_HITL_OR_READ_VERBS.has(canonVerb) && !input.ruleForcedAsk) {
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.33.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,11 +31,11 @@
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",
38
- "@canonmsg/rich-cards": "^0.10.2",
34
+ "@canonmsg/agent-sdk": "^9.0.0",
35
+ "@canonmsg/agent-tools": "^0.7.0",
36
+ "@canonmsg/coding-agent-host": "^0.7.0",
37
+ "@canonmsg/core": "^11.0.0",
38
+ "@canonmsg/rich-cards": "^0.10.3",
39
39
  "@modelcontextprotocol/sdk": "^1.30.0"
40
40
  },
41
41
  "engines": {