@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/host.js +202 -340
- package/dist/server.js +2 -3
- package/package.json +7 -7
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,
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
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
|
-
|
|
3019
|
+
materialized = await materializeMessageMedia({
|
|
3020
|
+
id: m.id,
|
|
3021
|
+
attachments: m.attachments ?? [],
|
|
3022
|
+
}, { agentId, conversationId: convoId });
|
|
3219
3023
|
}
|
|
3220
3024
|
catch (error) {
|
|
3221
|
-
|
|
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
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
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
|
-
|
|
3288
|
-
|
|
3289
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}]
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
35
|
-
"@canonmsg/agent-tools": "^0.
|
|
36
|
-
"@canonmsg/coding-agent-host": "^0.
|
|
37
|
-
"@canonmsg/core": "^
|
|
38
|
-
"@canonmsg/rich-cards": "^0.10.
|
|
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": ">=
|
|
42
|
+
"node": ">=22.22.3"
|
|
43
43
|
},
|
|
44
44
|
"keywords": [
|
|
45
45
|
"canon",
|