@canonmsg/claude-code-plugin 0.35.0 → 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.35.0",
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, 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, 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);
@@ -2846,9 +2867,14 @@ export async function main() {
2846
2867
  return roster ? buildCompactGroupContextLines(roster, 'initial').join('\n') : '';
2847
2868
  },
2848
2869
  currentArtifactAudience: () => ({ memberIds: conversationCache.get(conversationId)?.memberIds, agentId, ownerId }),
2870
+ onInputDeferred: (sourceMessageId) => settleInboundMessageId(sourceMessageId, false),
2849
2871
  onInputCompleted: (sourceMessageId) => {
2850
2872
  recoveryCheckpointsFor(conversationId).settle(sourceMessageId);
2851
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));
2852
2878
  },
2853
2879
  publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
2854
2880
  }, resumeId);
@@ -2873,134 +2899,6 @@ export async function main() {
2873
2899
  pendingSessionCreations.delete(conversationId);
2874
2900
  }
2875
2901
  }
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
2902
  const acceptedInboundMessageIds = new Set();
3005
2903
  const inFlightInboundMessageIds = new Set();
3006
2904
  function claimInboundMessageId(messageId) {
@@ -3071,223 +2969,177 @@ export async function main() {
3071
2969
  return false;
3072
2970
  }
3073
2971
  }
3074
- let startupRecoveryComplete = false;
3075
2972
  async function performMissedInboundRecovery() {
3076
- const knownBeforeRefresh = new Set(knownConversationIds);
3077
2973
  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
2974
  }
3137
2975
  const reconnectRecovery = createReconnectRecoveryCoordinator(performMissedInboundRecovery);
3138
2976
  await reconnectRecovery.recoverNow();
3139
2977
  // ── 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;
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) {
3217
3009
  try {
3218
- session = await getOrCreateSession(convoId);
3010
+ materialized = await materializeMessageMedia({
3011
+ id: m.id,
3012
+ attachments: m.attachments ?? [],
3013
+ }, { agentId, conversationId: convoId });
3219
3014
  }
3220
3015
  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;
3016
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
3240
3017
  }
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);
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',
3286
3044
  }
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);
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,
3290
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],
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 });
3291
3143
  },
3292
3144
  onMessageDeleted: (payload) => {
3293
3145
  removeQueuedInput(payload.conversationId, payload.messageId);
@@ -3436,6 +3288,7 @@ export async function main() {
3436
3288
  clearInterval(heartbeat);
3437
3289
  clearInterval(idleCheck);
3438
3290
  stream.stop();
3291
+ await inbound.close();
3439
3292
  approvalManager?.dispose();
3440
3293
  runtimeInputManager.dispose();
3441
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) => {
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.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.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.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",