@canonmsg/claude-code-plugin 0.35.0 → 0.36.1

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.35.0",
4
+ "version": "0.36.1",
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, 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, 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, hydrateCanonReplyContext, 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';
@@ -1332,7 +1332,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1332
1332
  const deliveredMessageIds = chunkedFailure
1333
1333
  ? chunkedFailure.deliveredMessageIds
1334
1334
  : resume.deliveredMessageIds;
1335
- const failure = classifyFinalDeliveryFailure(cause);
1335
+ const failure = isPendingCanonOperation(cause) ? 'retryable' : classifyFinalDeliveryFailure(cause);
1336
1336
  if (failure === 'permanent') {
1337
1337
  // Re-sending is pointless — the same request fails the same way. Surface
1338
1338
  // what can be surfaced and finish the turn instead of burning the retry
@@ -1635,7 +1635,16 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1635
1635
  .updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected')
1636
1636
  .catch(() => { });
1637
1637
  }
1638
- 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
+ }
1639
1648
  // resetTurnToIdle releases the slot, tears the published turn down (typing
1640
1649
  // signal, seeded /streaming, turn state) and drains the queue. On a closed
1641
1650
  // session it is a no-op, which is correct: the teardown owns that path.
@@ -1645,7 +1654,16 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1645
1654
  return dispatchClaudeInput(session, input, {
1646
1655
  openTurn,
1647
1656
  markAccepted: (dispatched) => markQueuedMessageAccepted(dispatched.sourceMessageId, dispatched.markAccepted),
1648
- 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
+ },
1649
1667
  send: sendInput,
1650
1668
  abandon: abandonDispatchedInput,
1651
1669
  });
@@ -2230,7 +2248,8 @@ export async function main() {
2230
2248
  throw error;
2231
2249
  }
2232
2250
  // ── Canon API client + RTDB auth ──
2233
- const client = new CanonClient(apiKey, baseUrl);
2251
+ const client = new CanonClient(apiKey, baseUrl, { environmentId: resolvedAgent.environmentId, streamUrl });
2252
+ const endpoint = await client.getEndpoint();
2234
2253
  const rtdb = initRTDBAuth(client, { rtdbUrl, firebaseApiKey });
2235
2254
  const typingSignals = createTypingStatusPublisher({
2236
2255
  setTyping: (conversationId, typing, status) => status
@@ -2554,6 +2573,8 @@ export async function main() {
2554
2573
  ...payload.changes,
2555
2574
  memberIds,
2556
2575
  });
2576
+ void client.rememberConversation(conversationCache.get(payload.conversationId))
2577
+ .catch((error) => console.error('[canon] Failed to persist conversation update:', error));
2557
2578
  }
2558
2579
  if (membershipChange) {
2559
2580
  pendingMembershipChanges.set(payload.conversationId, membershipChange);
@@ -2588,7 +2609,7 @@ export async function main() {
2588
2609
  ? Promise.resolve(input.hydratedPage)
2589
2610
  : client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
2590
2611
  ]);
2591
- return buildHydratedInboundContext({
2612
+ const hydrated = buildHydratedInboundContext({
2592
2613
  agentId,
2593
2614
  conversationId: input.conversationId,
2594
2615
  conversation,
@@ -2605,6 +2626,15 @@ export async function main() {
2605
2626
  membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
2606
2627
  groupContextMode: getGroupContextMode(input.conversationId, conversation),
2607
2628
  });
2629
+ return {
2630
+ ...hydrated,
2631
+ replyContext: await hydrateCanonReplyContext({
2632
+ client,
2633
+ conversationId: input.conversationId,
2634
+ message: input.message,
2635
+ messages: page?.messages,
2636
+ }),
2637
+ };
2608
2638
  }
2609
2639
  function closeSession(conversationId, options = {}) {
2610
2640
  const session = sessions.get(conversationId);
@@ -2846,9 +2876,14 @@ export async function main() {
2846
2876
  return roster ? buildCompactGroupContextLines(roster, 'initial').join('\n') : '';
2847
2877
  },
2848
2878
  currentArtifactAudience: () => ({ memberIds: conversationCache.get(conversationId)?.memberIds, agentId, ownerId }),
2879
+ onInputDeferred: (sourceMessageId) => settleInboundMessageId(sourceMessageId, false),
2849
2880
  onInputCompleted: (sourceMessageId) => {
2850
2881
  recoveryCheckpointsFor(conversationId).settle(sourceMessageId);
2851
2882
  settleInboundMessageId(sourceMessageId, true);
2883
+ const id = `message:${conversationId}:${sourceMessageId}`;
2884
+ void endpoint.getInbound(id).then((entry) => entry
2885
+ ? endpoint.setInboundState(id, 'settled', 'native-completed') : undefined)
2886
+ .catch((error) => console.error('[canon-host] Failed to settle native input:', error));
2852
2887
  },
2853
2888
  publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
2854
2889
  }, resumeId);
@@ -2873,134 +2908,6 @@ export async function main() {
2873
2908
  pendingSessionCreations.delete(conversationId);
2874
2909
  }
2875
2910
  }
2876
- async function enqueueRecoveredInboundMessage(input) {
2877
- const m = {
2878
- id: input.message.id,
2879
- senderId: input.message.senderId,
2880
- senderName: input.message.senderName,
2881
- senderType: input.message.senderType,
2882
- isOwner: input.message.isOwner,
2883
- text: input.message.text ?? undefined,
2884
- contentType: input.message.contentType,
2885
- attachments: input.message.attachments,
2886
- mentions: input.message.mentions,
2887
- reactions: input.message.reactions,
2888
- replyTo: input.message.replyTo ?? undefined,
2889
- replyToPosition: input.message.replyToPosition ?? undefined,
2890
- forwarded: input.message.forwarded,
2891
- forwardedFrom: input.message.forwardedFrom,
2892
- metadata: input.message.metadata,
2893
- contactCard: input.message.contactCard,
2894
- createdAt: input.message.createdAt,
2895
- };
2896
- if (m.senderId === agentId)
2897
- return 'handled';
2898
- let materialized = [];
2899
- if (m.id) {
2900
- try {
2901
- materialized = await materializeMessageMedia({
2902
- id: m.id,
2903
- attachments: m.attachments ?? [],
2904
- }, { agentId, conversationId: input.conversationId });
2905
- }
2906
- catch (error) {
2907
- console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to materialize recovered media:`, error instanceof Error ? error.message : error);
2908
- }
2909
- }
2910
- const sender = m.senderName || m.senderId;
2911
- const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2912
- const content = renderInboundContent(m, materialized);
2913
- const hydrated = await loadHydratedInboundContext({
2914
- conversationId: input.conversationId,
2915
- message: m,
2916
- senderName: sender,
2917
- isOwner,
2918
- hydratedPage: input.hydratedPage,
2919
- });
2920
- const behavior = input.hydratedPage?.behavior ?? hydrated.behavior;
2921
- const activeSelfContextId = hydrated.activeSelfContextId;
2922
- const selfContexts = hydrated.selfContexts;
2923
- const replyMedia = await materializePromptReplyContext({
2924
- replyContext: hydrated.replyContext,
2925
- agentId,
2926
- conversationId: input.conversationId,
2927
- logPrefix: `[canon-host] [${input.conversationId.slice(0, 8)}]`,
2928
- });
2929
- const replyContext = replyMedia.replyContext;
2930
- const participantContext = hydrated.participantContext;
2931
- const autoReply = decideAutoReply(participantContext, behavior);
2932
- if (!autoReply.allow) {
2933
- console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Suppressed recovered auto-reply: ${autoReply.reason}`);
2934
- return 'handled';
2935
- }
2936
- markGroupContextModeUsed(input.conversationId, participantContext.groupContextMode);
2937
- const turnMetadata = normalizeTurnMetadata(m.metadata);
2938
- const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
2939
- const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
2940
- let session;
2941
- try {
2942
- session = await getOrCreateSession(input.conversationId);
2943
- }
2944
- catch (error) {
2945
- const message = error instanceof Error ? error.message : String(error);
2946
- console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
2947
- await runtimeState.patchAgentSessionSnapshot(input.conversationId, {
2948
- configurationStatus: 'configuration_required',
2949
- lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
2950
- }).catch(() => { });
2951
- await markQueuedMessageAcceptedForConversation(input.conversationId, m.id ?? null, shouldMarkAccepted);
2952
- await sendMessageWithRetry(client, input.conversationId, LOCAL_CONFIGURATION_REQUIRED_MESSAGE, {
2953
- messageId: `claude-start-failed-${m.id}`,
2954
- ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
2955
- metadata: {
2956
- runtimeStatus: 'configuration_required',
2957
- turnSemantics: 'turn_complete',
2958
- replyBehavior: 'suppress_auto_reply',
2959
- },
2960
- }).catch(() => { });
2961
- return 'handled';
2962
- }
2963
- session.activeSelfContextId = activeSelfContextId;
2964
- const promptText = buildCanonPrompt({
2965
- content,
2966
- conversationId: input.conversationId,
2967
- participantContext,
2968
- behavior,
2969
- selfContexts,
2970
- activeSelfContextId,
2971
- provenance: hydrated.provenance,
2972
- replyContext,
2973
- message: m,
2974
- });
2975
- const commandInput = buildClaudeNativeCommandInput({
2976
- text: m.text,
2977
- senderType: m.senderType,
2978
- promptText,
2979
- commands: runtimeMetadata.commands,
2980
- });
2981
- const messageContent = await buildCanonUserContent({
2982
- promptText: commandInput.promptText,
2983
- materialized: [...replyMedia.materialized, ...materialized],
2984
- });
2985
- const turnModes = {
2986
- ...resolveClaudeTurnModes(participantContext),
2987
- commandContext: commandInput.commandContext,
2988
- };
2989
- session.enqueueInbound({
2990
- type: 'user',
2991
- message: {
2992
- role: 'user',
2993
- content: messageContent,
2994
- },
2995
- parent_tool_use_id: null,
2996
- origin: claudeOriginForCanonSender({
2997
- senderType: m.senderType,
2998
- senderId: m.senderId,
2999
- senderName: m.senderName,
3000
- }),
3001
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
3002
- return 'queued';
3003
- }
3004
2911
  const acceptedInboundMessageIds = new Set();
3005
2912
  const inFlightInboundMessageIds = new Set();
3006
2913
  function claimInboundMessageId(messageId) {
@@ -3071,223 +2978,177 @@ export async function main() {
3071
2978
  return false;
3072
2979
  }
3073
2980
  }
3074
- let startupRecoveryComplete = false;
3075
2981
  async function performMissedInboundRecovery() {
3076
- const knownBeforeRefresh = new Set(knownConversationIds);
3077
2982
  await refreshKnownConversationIds(true);
3078
- const conversationsDiscoveredWhileOffline = startupRecoveryComplete
3079
- ? new Set([...knownConversationIds].filter((id) => !knownBeforeRefresh.has(id)))
3080
- : new Set();
3081
- for (const conversationId of knownConversationIds) {
3082
- const recoveryCheckpoints = recoveryCheckpointsFor(conversationId);
3083
- const recoveryBatch = recoveryCheckpoints.reserveBatch();
3084
- try {
3085
- const recovered = await collectMissedInboundMessages({
3086
- fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
3087
- cursor: persistedConversationCursor(conversationId),
3088
- agentId,
3089
- requireContiguousCursor: true,
3090
- noCursorMode: conversationsDiscoveredWhileOffline.has(conversationId)
3091
- ? 'bounded-window'
3092
- : 'latest-only',
3093
- });
3094
- recoveryBatch.commit(recovered.messages.map((message) => message.id), recovered.mode === 'incomplete-gap' ? recovered.recoveryCursor : null);
3095
- if (recovered.mode === 'incomplete-gap') {
3096
- 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'}`);
3097
- }
3098
- for (const message of recovered.messages) {
3099
- if (!claimInboundMessageId(message.id))
3100
- continue;
3101
- try {
3102
- let queued = false;
3103
- if (!handleRuntimeReplyMessage(conversationId, message)) {
3104
- const trigger = shouldTriggerAgentTurn({
3105
- senderType: message.senderType,
3106
- metadata: message.metadata,
3107
- });
3108
- if (trigger.allow) {
3109
- queued = await enqueueRecoveredInboundMessage({
3110
- conversationId,
3111
- message,
3112
- hydratedPage: recovered.newestPage,
3113
- }) === 'queued';
3114
- }
3115
- }
3116
- if (!queued) {
3117
- recoveryCheckpoints.settle(message.id);
3118
- settleInboundMessageId(message.id, true);
3119
- }
3120
- }
3121
- catch (error) {
3122
- settleInboundMessageId(message.id, false);
3123
- throw error;
3124
- }
3125
- }
3126
- if (recovered.messages.length > 0) {
3127
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
3128
- }
3129
- }
3130
- catch (error) {
3131
- recoveryBatch.cancel();
3132
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
3133
- }
3134
- }
3135
- startupRecoveryComplete = true;
3136
2983
  }
3137
2984
  const reconnectRecovery = createReconnectRecoveryCoordinator(performMissedInboundRecovery);
3138
2985
  await reconnectRecovery.recoverNow();
3139
2986
  // ── Connect SSE stream for inbound Canon messages ──
3140
- const stream = new CanonStream({
3141
- apiKey,
3142
- agentId,
3143
- streamUrl,
3144
- handler: {
3145
- onMessage: (payload) => {
3146
- const m = payload.message;
3147
- if (m.senderId === agentId)
3148
- return;
3149
- const convoId = payload.conversationId;
3150
- if (!claimInboundMessageId(m.id))
3151
- return;
3152
- recoveryCheckpointsFor(convoId).track(m.id);
3153
- knownConversationIds.add(convoId);
3154
- if (handleRuntimeReplyMessage(convoId, m)) {
3155
- recoveryCheckpointsFor(convoId).settle(m.id);
3156
- settleInboundMessageId(m.id, true);
3157
- return;
3158
- }
3159
- const sender = m.senderName || m.senderId;
3160
- const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
3161
- if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
3162
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
3163
- recoveryCheckpointsFor(convoId).settle(m.id);
3164
- settleInboundMessageId(m.id, true);
3165
- return;
3166
- }
3167
- void (async () => {
3168
- let materialized = [];
3169
- if (m.id) {
3170
- try {
3171
- materialized = await materializeMessageMedia({
3172
- id: m.id,
3173
- attachments: m.attachments ?? [],
3174
- }, { agentId, conversationId: convoId });
3175
- }
3176
- catch (error) {
3177
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
3178
- }
3179
- }
3180
- const content = renderInboundContent(m, materialized);
3181
- const hydrated = await loadHydratedInboundContext({
3182
- conversationId: convoId,
3183
- message: m,
3184
- senderName: sender,
3185
- isOwner,
3186
- activeSelfContextId: payload.activeSelfContextId,
3187
- selfContexts: payload.selfContexts,
3188
- provenance: payload.provenance,
3189
- });
3190
- const behavior = payload.behavior ?? hydrated.behavior;
3191
- const activeSelfContextId = hydrated.activeSelfContextId;
3192
- const selfContexts = hydrated.selfContexts;
3193
- const replyMedia = await materializePromptReplyContext({
3194
- replyContext: hydrated.replyContext,
3195
- agentId,
3196
- conversationId: convoId,
3197
- logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
3198
- });
3199
- const replyContext = replyMedia.replyContext;
3200
- const participantContext = hydrated.participantContext;
3201
- const autoReply = payload.turnDispatch?.kind === 'run_turn'
3202
- ? {
3203
- allow: true,
3204
- reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
3205
- }
3206
- : decideAutoReply(participantContext, behavior);
3207
- if (!autoReply.allow) {
3208
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
3209
- return false;
3210
- }
3211
- markGroupContextModeUsed(convoId, participantContext.groupContextMode);
3212
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
3213
- const turnMetadata = normalizeTurnMetadata(m.metadata);
3214
- const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
3215
- const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
3216
- let session;
2987
+ const inbound = endpoint.acceptInbound({ kind: 'message.created',
2988
+ offer: async (event) => {
2989
+ const payload = event.data;
2990
+ const m = payload.message;
2991
+ if (m.senderId === agentId) {
2992
+ await endpoint.setInboundState(event.id, 'settled', 'own-message');
2993
+ return;
2994
+ }
2995
+ const convoId = payload.conversationId;
2996
+ if (!claimInboundMessageId(m.id))
2997
+ return;
2998
+ recoveryCheckpointsFor(convoId).track(m.id);
2999
+ knownConversationIds.add(convoId);
3000
+ if (handleRuntimeReplyMessage(convoId, m)) {
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
+ const sender = m.senderName || m.senderId;
3007
+ const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
3008
+ if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
3009
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
3010
+ recoveryCheckpointsFor(convoId).settle(m.id);
3011
+ settleInboundMessageId(m.id, true);
3012
+ await endpoint.setInboundState(`message:${convoId}:${m.id}`, 'settled', 'not-dispatched');
3013
+ return;
3014
+ }
3015
+ await (async () => {
3016
+ let materialized = [];
3017
+ if (m.id) {
3217
3018
  try {
3218
- session = await getOrCreateSession(convoId);
3019
+ materialized = await materializeMessageMedia({
3020
+ id: m.id,
3021
+ attachments: m.attachments ?? [],
3022
+ }, { agentId, conversationId: convoId });
3219
3023
  }
3220
3024
  catch (error) {
3221
- const message = error instanceof Error ? error.message : String(error);
3222
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
3223
- await runtimeState.patchAgentSessionSnapshot(convoId, {
3224
- configurationStatus: 'configuration_required',
3225
- lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
3226
- }).catch(() => { });
3227
- await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
3228
- await sendMessageWithRetry(client, convoId, LOCAL_CONFIGURATION_REQUIRED_MESSAGE, {
3229
- messageId: `claude-start-failed-${m.id}`,
3230
- ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
3231
- metadata: {
3232
- turnId: `claude-start:${m.id}`,
3233
- runtimeStatus: 'configuration_required',
3234
- turnSemantics: 'turn_complete',
3235
- replyBehavior: 'suppress_auto_reply',
3236
- },
3237
- ...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
3238
- }).catch(() => { });
3239
- return false;
3025
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
3240
3026
  }
3241
- session.activeSelfContextId = activeSelfContextId;
3242
- const promptText = buildCanonPrompt({
3243
- content,
3244
- conversationId: convoId,
3245
- participantContext,
3246
- behavior,
3247
- selfContexts,
3248
- activeSelfContextId,
3249
- provenance: hydrated.provenance,
3250
- replyContext,
3251
- message: m,
3252
- });
3253
- const commandInput = buildClaudeNativeCommandInput({
3254
- text: m.text,
3255
- senderType: m.senderType,
3256
- promptText,
3257
- commands: runtimeMetadata.commands,
3258
- });
3259
- const messageContent = await buildCanonUserContent({
3260
- promptText: commandInput.promptText,
3261
- materialized: [...replyMedia.materialized, ...materialized],
3262
- });
3263
- const turnModes = {
3264
- ...resolveClaudeTurnModes(participantContext),
3265
- commandContext: commandInput.commandContext,
3266
- replyAuthority: payload.replyAuthority ?? null,
3267
- };
3268
- session.enqueueInbound({
3269
- type: 'user',
3270
- message: {
3271
- role: 'user',
3272
- content: messageContent,
3273
- },
3274
- parent_tool_use_id: null,
3275
- origin: claudeOriginForCanonSender({
3276
- senderType: m.senderType,
3277
- senderId: m.senderId,
3278
- senderName: m.senderName,
3279
- }),
3280
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
3281
- return true;
3282
- })().then((queued) => {
3283
- if (!queued) {
3284
- recoveryCheckpointsFor(convoId).settle(m.id);
3285
- settleInboundMessageId(m.id, true);
3027
+ }
3028
+ const content = renderInboundContent(m, materialized);
3029
+ const hydrated = await loadHydratedInboundContext({
3030
+ conversationId: convoId,
3031
+ message: m,
3032
+ senderName: sender,
3033
+ isOwner,
3034
+ activeSelfContextId: payload.activeSelfContextId,
3035
+ selfContexts: payload.selfContexts,
3036
+ provenance: payload.provenance,
3037
+ });
3038
+ const behavior = payload.behavior ?? hydrated.behavior;
3039
+ const activeSelfContextId = hydrated.activeSelfContextId;
3040
+ const selfContexts = hydrated.selfContexts;
3041
+ const replyMedia = await materializePromptReplyContext({
3042
+ replyContext: hydrated.replyContext,
3043
+ agentId,
3044
+ conversationId: convoId,
3045
+ logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
3046
+ });
3047
+ const replyContext = replyMedia.replyContext;
3048
+ const participantContext = hydrated.participantContext;
3049
+ const autoReply = payload.turnDispatch?.kind === 'run_turn'
3050
+ ? {
3051
+ allow: true,
3052
+ reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
3286
3053
  }
3287
- }).catch((error) => {
3288
- settleInboundMessageId(m.id, false);
3289
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
3054
+ : decideAutoReply(participantContext, behavior);
3055
+ if (!autoReply.allow) {
3056
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
3057
+ return false;
3058
+ }
3059
+ markGroupContextModeUsed(convoId, participantContext.groupContextMode);
3060
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
3061
+ const turnMetadata = normalizeTurnMetadata(m.metadata);
3062
+ const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
3063
+ const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
3064
+ let session;
3065
+ try {
3066
+ session = await getOrCreateSession(convoId);
3067
+ }
3068
+ catch (error) {
3069
+ const message = error instanceof Error ? error.message : String(error);
3070
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
3071
+ await runtimeState.patchAgentSessionSnapshot(convoId, {
3072
+ configurationStatus: 'configuration_required',
3073
+ lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
3074
+ }).catch(() => { });
3075
+ await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
3076
+ await sendMessageWithRetry(client, convoId, LOCAL_CONFIGURATION_REQUIRED_MESSAGE, {
3077
+ messageId: `claude-start-failed-${m.id}`,
3078
+ ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
3079
+ metadata: {
3080
+ turnId: `claude-start:${m.id}`,
3081
+ runtimeStatus: 'configuration_required',
3082
+ turnSemantics: 'turn_complete',
3083
+ replyBehavior: 'suppress_auto_reply',
3084
+ },
3085
+ ...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
3086
+ }).catch(() => { });
3087
+ return false;
3088
+ }
3089
+ session.activeSelfContextId = activeSelfContextId;
3090
+ const promptText = buildCanonPrompt({
3091
+ content,
3092
+ conversationId: convoId,
3093
+ participantContext,
3094
+ behavior,
3095
+ selfContexts,
3096
+ activeSelfContextId,
3097
+ provenance: hydrated.provenance,
3098
+ replyContext,
3099
+ message: m,
3290
3100
  });
3101
+ const commandInput = buildClaudeNativeCommandInput({
3102
+ text: m.text,
3103
+ senderType: m.senderType,
3104
+ promptText,
3105
+ commands: runtimeMetadata.commands,
3106
+ });
3107
+ const messageContent = await buildCanonUserContent({
3108
+ promptText: commandInput.promptText,
3109
+ materialized: [...replyMedia.materialized, ...materialized],
3110
+ });
3111
+ const turnModes = {
3112
+ ...resolveClaudeTurnModes(participantContext),
3113
+ commandContext: commandInput.commandContext,
3114
+ replyAuthority: payload.replyAuthority ?? null,
3115
+ };
3116
+ session.enqueueInbound({
3117
+ type: 'user',
3118
+ message: {
3119
+ role: 'user',
3120
+ content: messageContent,
3121
+ },
3122
+ parent_tool_use_id: null,
3123
+ origin: claudeOriginForCanonSender({
3124
+ senderType: m.senderType,
3125
+ senderId: m.senderId,
3126
+ senderName: m.senderName,
3127
+ }),
3128
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
3129
+ return true;
3130
+ })().then(async (queued) => {
3131
+ if (!queued) {
3132
+ recoveryCheckpointsFor(convoId).settle(m.id);
3133
+ settleInboundMessageId(m.id, true);
3134
+ await endpoint.setInboundState(`message:${convoId}:${m.id}`, 'settled', 'not-dispatched');
3135
+ }
3136
+ }).catch((error) => {
3137
+ settleInboundMessageId(m.id, false);
3138
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
3139
+ throw error;
3140
+ });
3141
+ },
3142
+ onError: (error) => console.error('[canon-host] Endpoint input deferred:', error), });
3143
+ inbound.start();
3144
+ const stream = new CanonStream({
3145
+ endpoint,
3146
+ agentId,
3147
+ handler: {
3148
+ onMessage: (payload) => {
3149
+ void inbound.receive({ id: `message:${payload.conversationId}:${payload.message.id}`,
3150
+ kind: 'message.created', conversationId: payload.conversationId, durable: true,
3151
+ data: payload });
3291
3152
  },
3292
3153
  onMessageDeleted: (payload) => {
3293
3154
  removeQueuedInput(payload.conversationId, payload.messageId);
@@ -3436,6 +3297,7 @@ export async function main() {
3436
3297
  clearInterval(heartbeat);
3437
3298
  clearInterval(idleCheck);
3438
3299
  stream.stop();
3300
+ await inbound.close();
3439
3301
  approvalManager?.dispose();
3440
3302
  runtimeInputManager.dispose();
3441
3303
  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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.35.0",
3
+ "version": "0.36.1",
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.4.0",
35
- "@canonmsg/agent-tools": "^0.10.0",
36
- "@canonmsg/coding-agent-host": "^0.8.0",
37
- "@canonmsg/core": "^12.5.0",
38
- "@canonmsg/rich-cards": "^0.10.5",
34
+ "@canonmsg/agent-sdk": "^11.0.1",
35
+ "@canonmsg/agent-tools": "^0.11.0",
36
+ "@canonmsg/coding-agent-host": "^0.9.0",
37
+ "@canonmsg/core": "^13.0.4",
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",