@canonmsg/claude-code-plugin 0.34.5 → 0.36.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.34.5",
4
+ "version": "0.36.0",
5
5
  "channels": [
6
6
  {
7
7
  "server": "canon-channel",
package/dist/host.js CHANGED
@@ -27,10 +27,10 @@ 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, createRecoveryCheckpointTracker, createReconnectRecoveryCoordinator, STARTUP_RECOVERY_PAGE_SIZE, } from '@canonmsg/coding-agent-host';
30
+ import { captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, createRecoveryCheckpointTracker, createReconnectRecoveryCoordinator, } from '@canonmsg/coding-agent-host';
31
31
  import { buildCanonUserContent, renderClaudeInboundContent, withClaudeReplyContextMediaReferences, } from './canon-user-content.js';
32
32
  import { buildClaudeNativeCommandInput, createClaudeCommandContextHook, getClaudeNativeCommandOutput, } from './native-command-input.js';
33
- 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, createRuntimeHeartbeat, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, parseTurnVerbosityConfig, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, sendMessageWithRetry, sendMessageWithRetryChunked, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, resolveLocalRuntimeSessionState, saveRuntimeSessionState, clearRuntimeSessionState, publishHostSessionSnapshots, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
33
+ import { EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonGroupContext, buildCompactGroupContextLines, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, parseTurnVerbosityConfig, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, isPendingCanonOperation, sendMessageWithRetry, sendMessageWithRetryChunked, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, resolveLocalRuntimeSessionState, saveRuntimeSessionState, clearRuntimeSessionState, publishHostSessionSnapshots, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, resolveTurnVerbosity, upsertLocalRuntimeEntry, } from '@canonmsg/core';
34
34
  import { runCli } from '@canonmsg/core';
35
35
  import { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer } from '@canonmsg/agent-tools';
36
36
  import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
@@ -615,6 +615,22 @@ function createSession(conversationId, environment, agentId, client, typingSigna
615
615
  // Only Canon turns may own the active-input slot. Result ownership comes from
616
616
  // UUID correlation, while this slot drives live tool/dialog state.
617
617
  function bindActiveInput(input) {
618
+ if (claudeInputOwnsTurnSlot(input)) {
619
+ const roster = config.currentParticipantContext?.();
620
+ if (roster && input.commandContext) {
621
+ // Native slash-command arguments must stay byte-for-byte intact. Their
622
+ // context travels through UserPromptExpansion rather than the command.
623
+ input.commandContext = `${input.commandContext}\n\n${roster}`;
624
+ }
625
+ else if (roster) {
626
+ const content = input.msg.message.content;
627
+ input.msg = { ...input.msg, message: { ...input.msg.message,
628
+ content: typeof content === 'string'
629
+ ? `${content}\n\n${roster}`
630
+ : [...content, { type: 'text', text: roster }],
631
+ } };
632
+ }
633
+ }
618
634
  rememberDispatchedClaudeInput(dispatchedInputs, input);
619
635
  // The slot drives in-turn concerns (tool routing, dialogs, interrupts), so
620
636
  // it tracks the newest Canon turn. Result ownership is resolved by uuid, not
@@ -1316,7 +1332,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1316
1332
  const deliveredMessageIds = chunkedFailure
1317
1333
  ? chunkedFailure.deliveredMessageIds
1318
1334
  : resume.deliveredMessageIds;
1319
- const failure = classifyFinalDeliveryFailure(cause);
1335
+ const failure = isPendingCanonOperation(cause) ? 'retryable' : classifyFinalDeliveryFailure(cause);
1320
1336
  if (failure === 'permanent') {
1321
1337
  // Re-sending is pointless — the same request fails the same way. Surface
1322
1338
  // what can be surfaced and finish the turn instead of burning the retry
@@ -1359,8 +1375,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1359
1375
  return false;
1360
1376
  }
1361
1377
  }
1362
- function sendTurnArtifactFile(file) {
1378
+ function sendTurnArtifactFile(file, canPublish) {
1363
1379
  return sendMediaFileMessage(client, conversationId, file.path, '', {
1380
+ canPublish,
1364
1381
  ...(session.activeInput?.replyAuthority
1365
1382
  ? { replyAuthority: session.activeInput.replyAuthority }
1366
1383
  : {}),
@@ -1382,16 +1399,18 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1382
1399
  * `composeClaudeTurnFinal` makes for the text.
1383
1400
  */
1384
1401
  function routeArtifactsForInput(input, finalText) {
1402
+ const decideArtifactRouting = () => shouldRouteClaudeTurnArtifacts({
1403
+ turn: input,
1404
+ interruptedTurnKeys: session.interruptedTurnKeys,
1405
+ silencedTurnKeys: session.silencedTurnKeys,
1406
+ finalText,
1407
+ currentAudience: config.currentArtifactAudience?.() ?? { agentId, ownerId: config.ownerId },
1408
+ });
1385
1409
  return createTurnArtifactRouter({
1386
- decide: () => shouldRouteClaudeTurnArtifacts({
1387
- turn: input,
1388
- interruptedTurnKeys: session.interruptedTurnKeys,
1389
- silencedTurnKeys: session.silencedTurnKeys,
1390
- finalText,
1391
- }),
1410
+ decide: decideArtifactRouting,
1392
1411
  baseline: () => input.artifactBaseline,
1393
1412
  cwd: () => session.cwd,
1394
- send: (file) => sendTurnArtifactFile(file),
1413
+ send: (file) => sendTurnArtifactFile(file, () => decideArtifactRouting().route),
1395
1414
  log: (line) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] ${line}`),
1396
1415
  }).route();
1397
1416
  }
@@ -1616,7 +1635,16 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1616
1635
  .updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected')
1617
1636
  .catch(() => { });
1618
1637
  }
1619
- markInputCompleted(input);
1638
+ if (input.kind === 'canon' && input.sourceMessageId) {
1639
+ const id = `message:${conversationId}:${input.sourceMessageId}`;
1640
+ void client.getEndpoint().then(async (engine) => {
1641
+ const entry = await engine.getInbound(id);
1642
+ if (entry && ['observed', 'offered', 'deferred'].includes(entry.state)) {
1643
+ await engine.setInboundState(id, 'deferred', 'native-preflight');
1644
+ config.onInputDeferred?.(input.sourceMessageId);
1645
+ }
1646
+ }).catch((error) => console.error('[canon-host] Failed to defer native input:', error));
1647
+ }
1620
1648
  // resetTurnToIdle releases the slot, tears the published turn down (typing
1621
1649
  // signal, seeded /streaming, turn state) and drains the queue. On a closed
1622
1650
  // session it is a no-op, which is correct: the teardown owns that path.
@@ -1626,7 +1654,16 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1626
1654
  return dispatchClaudeInput(session, input, {
1627
1655
  openTurn,
1628
1656
  markAccepted: (dispatched) => markQueuedMessageAccepted(dispatched.sourceMessageId, dispatched.markAccepted),
1629
- prepareArtifacts: prepareArtifactsForInput,
1657
+ prepareArtifacts: async (input) => {
1658
+ await prepareArtifactsForInput(input);
1659
+ if (input.kind === 'canon' && input.sourceMessageId) {
1660
+ const engine = await client.getEndpoint();
1661
+ const id = `message:${conversationId}:${input.sourceMessageId}`;
1662
+ if (!await engine.claimInbound(id)) {
1663
+ throw new Error('Canon input already has an execution owner.');
1664
+ }
1665
+ }
1666
+ },
1630
1667
  send: sendInput,
1631
1668
  abandon: abandonDispatchedInput,
1632
1669
  });
@@ -2211,7 +2248,8 @@ export async function main() {
2211
2248
  throw error;
2212
2249
  }
2213
2250
  // ── Canon API client + RTDB auth ──
2214
- const client = new CanonClient(apiKey, baseUrl);
2251
+ const client = new CanonClient(apiKey, baseUrl, { environmentId: resolvedAgent.environmentId, streamUrl });
2252
+ const endpoint = await client.getEndpoint();
2215
2253
  const rtdb = initRTDBAuth(client, { rtdbUrl, firebaseApiKey });
2216
2254
  const typingSignals = createTypingStatusPublisher({
2217
2255
  setTyping: (conversationId, typing, status) => status
@@ -2532,19 +2570,23 @@ export async function main() {
2532
2570
  if (cached) {
2533
2571
  conversationCache.set(payload.conversationId, {
2534
2572
  ...cached,
2573
+ ...payload.changes,
2535
2574
  memberIds,
2536
2575
  });
2576
+ void client.rememberConversation(conversationCache.get(payload.conversationId))
2577
+ .catch((error) => console.error('[canon] Failed to persist conversation update:', error));
2537
2578
  }
2538
2579
  if (membershipChange) {
2539
2580
  pendingMembershipChanges.set(payload.conversationId, membershipChange);
2540
2581
  }
2541
2582
  if (!memberIds.includes(agentId)) {
2542
2583
  knownConversationIds.delete(payload.conversationId);
2543
- conversationCache.delete(payload.conversationId);
2584
+ pendingMembershipChanges.delete(payload.conversationId);
2585
+ closeSession(payload.conversationId);
2544
2586
  }
2545
2587
  }
2546
2588
  function getGroupContextMode(conversationId, conversation) {
2547
- if (conversation?.type !== 'group')
2589
+ if (!conversation)
2548
2590
  return undefined;
2549
2591
  if (pendingMembershipChanges.has(conversationId))
2550
2592
  return 'membership_change';
@@ -2562,7 +2604,7 @@ export async function main() {
2562
2604
  }
2563
2605
  async function loadHydratedInboundContext(input) {
2564
2606
  const [conversation, page] = await Promise.all([
2565
- getConversationMeta(input.conversationId),
2607
+ getConversationMeta(input.conversationId, { refreshIfMemberMissing: agentId }),
2566
2608
  input.hydratedPage
2567
2609
  ? Promise.resolve(input.hydratedPage)
2568
2610
  : client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
@@ -2571,6 +2613,7 @@ export async function main() {
2571
2613
  agentId,
2572
2614
  conversationId: input.conversationId,
2573
2615
  conversation,
2616
+ requireAgentMembership: true,
2574
2617
  page,
2575
2618
  activeSelfContextId: input.activeSelfContextId,
2576
2619
  selfContexts: input.selfContexts,
@@ -2704,6 +2747,10 @@ export async function main() {
2704
2747
  }
2705
2748
  }
2706
2749
  async function getOrCreateSession(conversationId) {
2750
+ const current = conversationCache.get(conversationId);
2751
+ if (current && !current.memberIds.includes(agentId)) {
2752
+ throw new Error('Agent is no longer a member of this conversation');
2753
+ }
2707
2754
  knownConversationIds.add(conversationId);
2708
2755
  const existing = sessions.get(conversationId);
2709
2756
  if (existing && !existing.closed) {
@@ -2813,9 +2860,21 @@ export async function main() {
2813
2860
  runtimeId,
2814
2861
  ownerId,
2815
2862
  communicationEnabled,
2863
+ currentParticipantContext: () => {
2864
+ const roster = buildCanonGroupContext({
2865
+ conversation: conversationCache.get(conversationId), messages: [], agentId, ownerId, ownerName,
2866
+ });
2867
+ return roster ? buildCompactGroupContextLines(roster, 'initial').join('\n') : '';
2868
+ },
2869
+ currentArtifactAudience: () => ({ memberIds: conversationCache.get(conversationId)?.memberIds, agentId, ownerId }),
2870
+ onInputDeferred: (sourceMessageId) => settleInboundMessageId(sourceMessageId, false),
2816
2871
  onInputCompleted: (sourceMessageId) => {
2817
2872
  recoveryCheckpointsFor(conversationId).settle(sourceMessageId);
2818
2873
  settleInboundMessageId(sourceMessageId, true);
2874
+ const id = `message:${conversationId}:${sourceMessageId}`;
2875
+ void endpoint.getInbound(id).then((entry) => entry
2876
+ ? endpoint.setInboundState(id, 'settled', 'native-completed') : undefined)
2877
+ .catch((error) => console.error('[canon-host] Failed to settle native input:', error));
2819
2878
  },
2820
2879
  publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
2821
2880
  }, resumeId);
@@ -2840,134 +2899,6 @@ export async function main() {
2840
2899
  pendingSessionCreations.delete(conversationId);
2841
2900
  }
2842
2901
  }
2843
- async function enqueueRecoveredInboundMessage(input) {
2844
- const m = {
2845
- id: input.message.id,
2846
- senderId: input.message.senderId,
2847
- senderName: input.message.senderName,
2848
- senderType: input.message.senderType,
2849
- isOwner: input.message.isOwner,
2850
- text: input.message.text ?? undefined,
2851
- contentType: input.message.contentType,
2852
- attachments: input.message.attachments,
2853
- mentions: input.message.mentions,
2854
- reactions: input.message.reactions,
2855
- replyTo: input.message.replyTo ?? undefined,
2856
- replyToPosition: input.message.replyToPosition ?? undefined,
2857
- forwarded: input.message.forwarded,
2858
- forwardedFrom: input.message.forwardedFrom,
2859
- metadata: input.message.metadata,
2860
- contactCard: input.message.contactCard,
2861
- createdAt: input.message.createdAt,
2862
- };
2863
- if (m.senderId === agentId)
2864
- return 'handled';
2865
- let materialized = [];
2866
- if (m.id) {
2867
- try {
2868
- materialized = await materializeMessageMedia({
2869
- id: m.id,
2870
- attachments: m.attachments ?? [],
2871
- }, { agentId, conversationId: input.conversationId });
2872
- }
2873
- catch (error) {
2874
- console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to materialize recovered media:`, error instanceof Error ? error.message : error);
2875
- }
2876
- }
2877
- const sender = m.senderName || m.senderId;
2878
- const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2879
- const content = renderInboundContent(m, materialized);
2880
- const hydrated = await loadHydratedInboundContext({
2881
- conversationId: input.conversationId,
2882
- message: m,
2883
- senderName: sender,
2884
- isOwner,
2885
- hydratedPage: input.hydratedPage,
2886
- });
2887
- const behavior = input.hydratedPage?.behavior ?? hydrated.behavior;
2888
- const activeSelfContextId = hydrated.activeSelfContextId;
2889
- const selfContexts = hydrated.selfContexts;
2890
- const replyMedia = await materializePromptReplyContext({
2891
- replyContext: hydrated.replyContext,
2892
- agentId,
2893
- conversationId: input.conversationId,
2894
- logPrefix: `[canon-host] [${input.conversationId.slice(0, 8)}]`,
2895
- });
2896
- const replyContext = replyMedia.replyContext;
2897
- const participantContext = hydrated.participantContext;
2898
- const autoReply = decideAutoReply(participantContext, behavior);
2899
- if (!autoReply.allow) {
2900
- console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Suppressed recovered auto-reply: ${autoReply.reason}`);
2901
- return 'handled';
2902
- }
2903
- markGroupContextModeUsed(input.conversationId, participantContext.groupContextMode);
2904
- const turnMetadata = normalizeTurnMetadata(m.metadata);
2905
- const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
2906
- const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
2907
- let session;
2908
- try {
2909
- session = await getOrCreateSession(input.conversationId);
2910
- }
2911
- catch (error) {
2912
- const message = error instanceof Error ? error.message : String(error);
2913
- console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
2914
- await runtimeState.patchAgentSessionSnapshot(input.conversationId, {
2915
- configurationStatus: 'configuration_required',
2916
- lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
2917
- }).catch(() => { });
2918
- await markQueuedMessageAcceptedForConversation(input.conversationId, m.id ?? null, shouldMarkAccepted);
2919
- await sendMessageWithRetry(client, input.conversationId, LOCAL_CONFIGURATION_REQUIRED_MESSAGE, {
2920
- messageId: `claude-start-failed-${m.id}`,
2921
- ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2922
- metadata: {
2923
- runtimeStatus: 'configuration_required',
2924
- turnSemantics: 'turn_complete',
2925
- replyBehavior: 'suppress_auto_reply',
2926
- },
2927
- }).catch(() => { });
2928
- return 'handled';
2929
- }
2930
- session.activeSelfContextId = activeSelfContextId;
2931
- const promptText = buildCanonPrompt({
2932
- content,
2933
- conversationId: input.conversationId,
2934
- participantContext,
2935
- behavior,
2936
- selfContexts,
2937
- activeSelfContextId,
2938
- provenance: hydrated.provenance,
2939
- replyContext,
2940
- message: m,
2941
- });
2942
- const commandInput = buildClaudeNativeCommandInput({
2943
- text: m.text,
2944
- senderType: m.senderType,
2945
- promptText,
2946
- commands: runtimeMetadata.commands,
2947
- });
2948
- const messageContent = await buildCanonUserContent({
2949
- promptText: commandInput.promptText,
2950
- materialized: [...replyMedia.materialized, ...materialized],
2951
- });
2952
- const turnModes = {
2953
- ...resolveClaudeTurnModes(participantContext),
2954
- commandContext: commandInput.commandContext,
2955
- };
2956
- session.enqueueInbound({
2957
- type: 'user',
2958
- message: {
2959
- role: 'user',
2960
- content: messageContent,
2961
- },
2962
- parent_tool_use_id: null,
2963
- origin: claudeOriginForCanonSender({
2964
- senderType: m.senderType,
2965
- senderId: m.senderId,
2966
- senderName: m.senderName,
2967
- }),
2968
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
2969
- return 'queued';
2970
- }
2971
2902
  const acceptedInboundMessageIds = new Set();
2972
2903
  const inFlightInboundMessageIds = new Set();
2973
2904
  function claimInboundMessageId(messageId) {
@@ -3038,223 +2969,177 @@ export async function main() {
3038
2969
  return false;
3039
2970
  }
3040
2971
  }
3041
- let startupRecoveryComplete = false;
3042
2972
  async function performMissedInboundRecovery() {
3043
- const knownBeforeRefresh = new Set(knownConversationIds);
3044
2973
  await refreshKnownConversationIds(true);
3045
- const conversationsDiscoveredWhileOffline = startupRecoveryComplete
3046
- ? new Set([...knownConversationIds].filter((id) => !knownBeforeRefresh.has(id)))
3047
- : new Set();
3048
- for (const conversationId of knownConversationIds) {
3049
- const recoveryCheckpoints = recoveryCheckpointsFor(conversationId);
3050
- const recoveryBatch = recoveryCheckpoints.reserveBatch();
3051
- try {
3052
- const recovered = await collectMissedInboundMessages({
3053
- fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
3054
- cursor: persistedConversationCursor(conversationId),
3055
- agentId,
3056
- requireContiguousCursor: true,
3057
- noCursorMode: conversationsDiscoveredWhileOffline.has(conversationId)
3058
- ? 'bounded-window'
3059
- : 'latest-only',
3060
- });
3061
- recoveryBatch.commit(recovered.messages.map((message) => message.id), recovered.mode === 'incomplete-gap' ? recovered.recoveryCursor : null);
3062
- if (recovered.mode === 'incomplete-gap') {
3063
- 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'}`);
3064
- }
3065
- for (const message of recovered.messages) {
3066
- if (!claimInboundMessageId(message.id))
3067
- continue;
3068
- try {
3069
- let queued = false;
3070
- if (!handleRuntimeReplyMessage(conversationId, message)) {
3071
- const trigger = shouldTriggerAgentTurn({
3072
- senderType: message.senderType,
3073
- metadata: message.metadata,
3074
- });
3075
- if (trigger.allow) {
3076
- queued = await enqueueRecoveredInboundMessage({
3077
- conversationId,
3078
- message,
3079
- hydratedPage: recovered.newestPage,
3080
- }) === 'queued';
3081
- }
3082
- }
3083
- if (!queued) {
3084
- recoveryCheckpoints.settle(message.id);
3085
- settleInboundMessageId(message.id, true);
3086
- }
3087
- }
3088
- catch (error) {
3089
- settleInboundMessageId(message.id, false);
3090
- throw error;
3091
- }
3092
- }
3093
- if (recovered.messages.length > 0) {
3094
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
3095
- }
3096
- }
3097
- catch (error) {
3098
- recoveryBatch.cancel();
3099
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
3100
- }
3101
- }
3102
- startupRecoveryComplete = true;
3103
2974
  }
3104
2975
  const reconnectRecovery = createReconnectRecoveryCoordinator(performMissedInboundRecovery);
3105
2976
  await reconnectRecovery.recoverNow();
3106
2977
  // ── Connect SSE stream for inbound Canon messages ──
3107
- const stream = new CanonStream({
3108
- apiKey,
3109
- agentId,
3110
- streamUrl,
3111
- handler: {
3112
- onMessage: (payload) => {
3113
- const m = payload.message;
3114
- if (m.senderId === agentId)
3115
- return;
3116
- const convoId = payload.conversationId;
3117
- if (!claimInboundMessageId(m.id))
3118
- return;
3119
- recoveryCheckpointsFor(convoId).track(m.id);
3120
- knownConversationIds.add(convoId);
3121
- if (handleRuntimeReplyMessage(convoId, m)) {
3122
- recoveryCheckpointsFor(convoId).settle(m.id);
3123
- settleInboundMessageId(m.id, true);
3124
- return;
3125
- }
3126
- const sender = m.senderName || m.senderId;
3127
- const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
3128
- if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
3129
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
3130
- recoveryCheckpointsFor(convoId).settle(m.id);
3131
- settleInboundMessageId(m.id, true);
3132
- return;
3133
- }
3134
- void (async () => {
3135
- let materialized = [];
3136
- if (m.id) {
3137
- try {
3138
- materialized = await materializeMessageMedia({
3139
- id: m.id,
3140
- attachments: m.attachments ?? [],
3141
- }, { agentId, conversationId: convoId });
3142
- }
3143
- catch (error) {
3144
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
3145
- }
3146
- }
3147
- const content = renderInboundContent(m, materialized);
3148
- const hydrated = await loadHydratedInboundContext({
3149
- conversationId: convoId,
3150
- message: m,
3151
- senderName: sender,
3152
- isOwner,
3153
- activeSelfContextId: payload.activeSelfContextId,
3154
- selfContexts: payload.selfContexts,
3155
- provenance: payload.provenance,
3156
- });
3157
- const behavior = payload.behavior ?? hydrated.behavior;
3158
- const activeSelfContextId = hydrated.activeSelfContextId;
3159
- const selfContexts = hydrated.selfContexts;
3160
- const replyMedia = await materializePromptReplyContext({
3161
- replyContext: hydrated.replyContext,
3162
- agentId,
3163
- conversationId: convoId,
3164
- logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
3165
- });
3166
- const replyContext = replyMedia.replyContext;
3167
- const participantContext = hydrated.participantContext;
3168
- const autoReply = payload.turnDispatch?.kind === 'run_turn'
3169
- ? {
3170
- allow: true,
3171
- reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
3172
- }
3173
- : decideAutoReply(participantContext, behavior);
3174
- if (!autoReply.allow) {
3175
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
3176
- return false;
3177
- }
3178
- markGroupContextModeUsed(convoId, participantContext.groupContextMode);
3179
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
3180
- const turnMetadata = normalizeTurnMetadata(m.metadata);
3181
- const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
3182
- const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
3183
- let session;
2978
+ const inbound = endpoint.acceptInbound({ kind: 'message.created',
2979
+ offer: async (event) => {
2980
+ const payload = event.data;
2981
+ const m = payload.message;
2982
+ if (m.senderId === agentId) {
2983
+ await endpoint.setInboundState(event.id, 'settled', 'own-message');
2984
+ return;
2985
+ }
2986
+ const convoId = payload.conversationId;
2987
+ if (!claimInboundMessageId(m.id))
2988
+ return;
2989
+ recoveryCheckpointsFor(convoId).track(m.id);
2990
+ knownConversationIds.add(convoId);
2991
+ if (handleRuntimeReplyMessage(convoId, m)) {
2992
+ recoveryCheckpointsFor(convoId).settle(m.id);
2993
+ settleInboundMessageId(m.id, true);
2994
+ await endpoint.setInboundState(`message:${convoId}:${m.id}`, 'settled', 'not-dispatched');
2995
+ return;
2996
+ }
2997
+ const sender = m.senderName || m.senderId;
2998
+ const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2999
+ if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
3000
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
3001
+ recoveryCheckpointsFor(convoId).settle(m.id);
3002
+ settleInboundMessageId(m.id, true);
3003
+ await endpoint.setInboundState(`message:${convoId}:${m.id}`, 'settled', 'not-dispatched');
3004
+ return;
3005
+ }
3006
+ await (async () => {
3007
+ let materialized = [];
3008
+ if (m.id) {
3184
3009
  try {
3185
- session = await getOrCreateSession(convoId);
3010
+ materialized = await materializeMessageMedia({
3011
+ id: m.id,
3012
+ attachments: m.attachments ?? [],
3013
+ }, { agentId, conversationId: convoId });
3186
3014
  }
3187
3015
  catch (error) {
3188
- const message = error instanceof Error ? error.message : String(error);
3189
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
3190
- await runtimeState.patchAgentSessionSnapshot(convoId, {
3191
- configurationStatus: 'configuration_required',
3192
- lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
3193
- }).catch(() => { });
3194
- await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
3195
- await sendMessageWithRetry(client, convoId, LOCAL_CONFIGURATION_REQUIRED_MESSAGE, {
3196
- messageId: `claude-start-failed-${m.id}`,
3197
- ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
3198
- metadata: {
3199
- turnId: `claude-start:${m.id}`,
3200
- runtimeStatus: 'configuration_required',
3201
- turnSemantics: 'turn_complete',
3202
- replyBehavior: 'suppress_auto_reply',
3203
- },
3204
- ...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
3205
- }).catch(() => { });
3206
- return false;
3016
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
3207
3017
  }
3208
- session.activeSelfContextId = activeSelfContextId;
3209
- const promptText = buildCanonPrompt({
3210
- content,
3211
- conversationId: convoId,
3212
- participantContext,
3213
- behavior,
3214
- selfContexts,
3215
- activeSelfContextId,
3216
- provenance: hydrated.provenance,
3217
- replyContext,
3218
- message: m,
3219
- });
3220
- const commandInput = buildClaudeNativeCommandInput({
3221
- text: m.text,
3222
- senderType: m.senderType,
3223
- promptText,
3224
- commands: runtimeMetadata.commands,
3225
- });
3226
- const messageContent = await buildCanonUserContent({
3227
- promptText: commandInput.promptText,
3228
- materialized: [...replyMedia.materialized, ...materialized],
3229
- });
3230
- const turnModes = {
3231
- ...resolveClaudeTurnModes(participantContext),
3232
- commandContext: commandInput.commandContext,
3233
- replyAuthority: payload.replyAuthority ?? null,
3234
- };
3235
- session.enqueueInbound({
3236
- type: 'user',
3237
- message: {
3238
- role: 'user',
3239
- content: messageContent,
3240
- },
3241
- parent_tool_use_id: null,
3242
- origin: claudeOriginForCanonSender({
3243
- senderType: m.senderType,
3244
- senderId: m.senderId,
3245
- senderName: m.senderName,
3246
- }),
3247
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
3248
- return true;
3249
- })().then((queued) => {
3250
- if (!queued) {
3251
- recoveryCheckpointsFor(convoId).settle(m.id);
3252
- settleInboundMessageId(m.id, true);
3018
+ }
3019
+ const content = renderInboundContent(m, materialized);
3020
+ const hydrated = await loadHydratedInboundContext({
3021
+ conversationId: convoId,
3022
+ message: m,
3023
+ senderName: sender,
3024
+ isOwner,
3025
+ activeSelfContextId: payload.activeSelfContextId,
3026
+ selfContexts: payload.selfContexts,
3027
+ provenance: payload.provenance,
3028
+ });
3029
+ const behavior = payload.behavior ?? hydrated.behavior;
3030
+ const activeSelfContextId = hydrated.activeSelfContextId;
3031
+ const selfContexts = hydrated.selfContexts;
3032
+ const replyMedia = await materializePromptReplyContext({
3033
+ replyContext: hydrated.replyContext,
3034
+ agentId,
3035
+ conversationId: convoId,
3036
+ logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
3037
+ });
3038
+ const replyContext = replyMedia.replyContext;
3039
+ const participantContext = hydrated.participantContext;
3040
+ const autoReply = payload.turnDispatch?.kind === 'run_turn'
3041
+ ? {
3042
+ allow: true,
3043
+ reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
3253
3044
  }
3254
- }).catch((error) => {
3255
- settleInboundMessageId(m.id, false);
3256
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
3045
+ : decideAutoReply(participantContext, behavior);
3046
+ if (!autoReply.allow) {
3047
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
3048
+ return false;
3049
+ }
3050
+ markGroupContextModeUsed(convoId, participantContext.groupContextMode);
3051
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
3052
+ const turnMetadata = normalizeTurnMetadata(m.metadata);
3053
+ const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
3054
+ const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
3055
+ let session;
3056
+ try {
3057
+ session = await getOrCreateSession(convoId);
3058
+ }
3059
+ catch (error) {
3060
+ const message = error instanceof Error ? error.message : String(error);
3061
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
3062
+ await runtimeState.patchAgentSessionSnapshot(convoId, {
3063
+ configurationStatus: 'configuration_required',
3064
+ lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
3065
+ }).catch(() => { });
3066
+ await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
3067
+ await sendMessageWithRetry(client, convoId, LOCAL_CONFIGURATION_REQUIRED_MESSAGE, {
3068
+ messageId: `claude-start-failed-${m.id}`,
3069
+ ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
3070
+ metadata: {
3071
+ turnId: `claude-start:${m.id}`,
3072
+ runtimeStatus: 'configuration_required',
3073
+ turnSemantics: 'turn_complete',
3074
+ replyBehavior: 'suppress_auto_reply',
3075
+ },
3076
+ ...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
3077
+ }).catch(() => { });
3078
+ return false;
3079
+ }
3080
+ session.activeSelfContextId = activeSelfContextId;
3081
+ const promptText = buildCanonPrompt({
3082
+ content,
3083
+ conversationId: convoId,
3084
+ participantContext,
3085
+ behavior,
3086
+ selfContexts,
3087
+ activeSelfContextId,
3088
+ provenance: hydrated.provenance,
3089
+ replyContext,
3090
+ message: m,
3091
+ });
3092
+ const commandInput = buildClaudeNativeCommandInput({
3093
+ text: m.text,
3094
+ senderType: m.senderType,
3095
+ promptText,
3096
+ commands: runtimeMetadata.commands,
3097
+ });
3098
+ const messageContent = await buildCanonUserContent({
3099
+ promptText: commandInput.promptText,
3100
+ materialized: [...replyMedia.materialized, ...materialized],
3257
3101
  });
3102
+ const turnModes = {
3103
+ ...resolveClaudeTurnModes(participantContext),
3104
+ commandContext: commandInput.commandContext,
3105
+ replyAuthority: payload.replyAuthority ?? null,
3106
+ };
3107
+ session.enqueueInbound({
3108
+ type: 'user',
3109
+ message: {
3110
+ role: 'user',
3111
+ content: messageContent,
3112
+ },
3113
+ parent_tool_use_id: null,
3114
+ origin: claudeOriginForCanonSender({
3115
+ senderType: m.senderType,
3116
+ senderId: m.senderId,
3117
+ senderName: m.senderName,
3118
+ }),
3119
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
3120
+ return true;
3121
+ })().then(async (queued) => {
3122
+ if (!queued) {
3123
+ recoveryCheckpointsFor(convoId).settle(m.id);
3124
+ settleInboundMessageId(m.id, true);
3125
+ await endpoint.setInboundState(`message:${convoId}:${m.id}`, 'settled', 'not-dispatched');
3126
+ }
3127
+ }).catch((error) => {
3128
+ settleInboundMessageId(m.id, false);
3129
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
3130
+ throw error;
3131
+ });
3132
+ },
3133
+ onError: (error) => console.error('[canon-host] Endpoint input deferred:', error), });
3134
+ inbound.start();
3135
+ const stream = new CanonStream({
3136
+ endpoint,
3137
+ agentId,
3138
+ handler: {
3139
+ onMessage: (payload) => {
3140
+ void inbound.receive({ id: `message:${payload.conversationId}:${payload.message.id}`,
3141
+ kind: 'message.created', conversationId: payload.conversationId, durable: true,
3142
+ data: payload });
3258
3143
  },
3259
3144
  onMessageDeleted: (payload) => {
3260
3145
  removeQueuedInput(payload.conversationId, payload.messageId);
@@ -3403,6 +3288,7 @@ export async function main() {
3403
3288
  clearInterval(heartbeat);
3404
3289
  clearInterval(idleCheck);
3405
3290
  stream.stop();
3291
+ await inbound.close();
3406
3292
  approvalManager?.dispose();
3407
3293
  runtimeInputManager.dispose();
3408
3294
  await runtimeHeartbeat.dispose();
package/dist/server.js CHANGED
@@ -578,7 +578,7 @@ async function startChannel() {
578
578
  if (profile) {
579
579
  console.error(`[canon] Using agent profile: ${profile}`);
580
580
  }
581
- client = new CanonClient(apiKey, baseUrl);
581
+ client = new CanonClient(apiKey, baseUrl, { environmentId: resolvedRuntime.environmentId, streamUrl: resolvedRuntime.streamUrl });
582
582
  // Get agent identity — try /agents/me first, fall back to /agents/auth-token
583
583
  let agentId;
584
584
  try {
@@ -645,9 +645,8 @@ async function startChannel() {
645
645
  }
646
646
  // Start SSE stream
647
647
  stream = new CanonStream({
648
- apiKey,
648
+ endpoint: await client.getEndpoint(),
649
649
  agentId,
650
- streamUrl: resolvedRuntime.streamUrl,
651
650
  handler: {
652
651
  onMessage: handleInboundMessage,
653
652
  onAgentContext: (ctx) => {
@@ -142,6 +142,11 @@ export declare function shouldRouteClaudeTurnArtifacts(input: {
142
142
  interruptedTurnKeys: ReadonlySet<string>;
143
143
  silencedTurnKeys: ReadonlySet<string>;
144
144
  finalText: string | null | undefined;
145
+ currentAudience?: {
146
+ memberIds?: readonly string[];
147
+ agentId: string;
148
+ ownerId: string | null | undefined;
149
+ };
145
150
  }): TurnArtifactRoutingDecision;
146
151
  export declare function rememberDispatchedClaudeInput(dispatched: Map<string, ClaudeInputEnvelope>, input: ClaudeInputEnvelope): void;
147
152
  /**
@@ -74,8 +74,12 @@ export function shouldDeliverClaudeFinal(input) {
74
74
  * `DEFAULT_SILENT_TURN_PRECEDENCE` moves a turn's text and its files together.
75
75
  */
76
76
  export function shouldRouteClaudeTurnArtifacts(input) {
77
+ const audience = input.currentAudience;
78
+ const ownerPair = !audience || (audience.ownerId !== audience.agentId
79
+ && Boolean(audience.ownerId) && audience.memberIds?.length === 2
80
+ && audience.memberIds.includes(audience.agentId) && audience.memberIds.includes(audience.ownerId));
77
81
  return resolveTurnArtifactRouting({
78
- artifactRoutingMode: input.turn.artifactRoutingMode,
82
+ artifactRoutingMode: ownerPair ? input.turn.artifactRoutingMode : 'disabled',
79
83
  interrupted: input.interruptedTurnKeys.has(input.turn.turnKey),
80
84
  silenced: isSilentTurnSuppressed({
81
85
  silenced: input.silencedTurnKeys.has(input.turn.turnKey),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.34.5",
3
+ "version": "0.36.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,15 +31,15 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "0.3.228",
34
- "@canonmsg/agent-sdk": "^10.3.0",
35
- "@canonmsg/agent-tools": "^0.9.1",
36
- "@canonmsg/coding-agent-host": "^0.7.1",
37
- "@canonmsg/core": "^12.4.0",
38
- "@canonmsg/rich-cards": "^0.10.5",
34
+ "@canonmsg/agent-sdk": "^11.0.0",
35
+ "@canonmsg/agent-tools": "^0.11.0",
36
+ "@canonmsg/coding-agent-host": "^0.9.0",
37
+ "@canonmsg/core": "^13.0.0",
38
+ "@canonmsg/rich-cards": "^0.10.6",
39
39
  "@modelcontextprotocol/sdk": "^1.30.0"
40
40
  },
41
41
  "engines": {
42
- "node": ">=18.0.0"
42
+ "node": ">=22.22.3"
43
43
  },
44
44
  "keywords": [
45
45
  "canon",