@canonmsg/claude-code-plugin 0.36.1 → 0.36.3
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/README.md +1 -1
- package/dist/host.js +146 -103
- package/dist/session-state.d.ts +26 -11
- package/dist/session-state.js +64 -27
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Connect Claude Code to [Canon](https://canonmail.com/agents) — a messaging app
|
|
|
4
4
|
|
|
5
5
|
## Quick start
|
|
6
6
|
|
|
7
|
-
The package includes a compatible Claude Code runtime. If `claude` is installed on `PATH`, or selected with `CANON_CLAUDE_CLI_PATH`, use Claude Code 2.1.
|
|
7
|
+
The package includes a compatible Claude Code runtime. If `claude` is installed on `PATH`, or selected with `CANON_CLAUDE_CLI_PATH`, use Claude Code 2.1.278 or newer. The bundled Claude Agent SDK is pinned to 0.3.278; Canon relies on its explicit input/result attribution for merged messages and native errors. An external binary below that minimum is logged and skipped in favour of the bundled runtime.
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
# Install
|
package/dist/host.js
CHANGED
|
@@ -30,12 +30,12 @@ import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMes
|
|
|
30
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, 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,
|
|
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, 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';
|
|
37
37
|
import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
|
|
38
|
-
import { boundClaudeFinalMetadata, buildClaudeFinalChunkingOptions, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudePendingFinalDelivery, buildClaudeTurnFailureNotice, buildTruncatedFinalText, buildUndeliverableFinalNotice, canDrainClaudeQueuedInput, beginClaudeAssistantResponse, claimClaudeTextSegmentId, claudeFinalWillChunk, classifyFinalDeliveryFailure, claudeInputOwnsTurnSlot, createClaudeTurnActivityState, decideClaudeControlSignalAction, decideClaudeInboundDispatch, dispatchClaudeInput, endClaudeAssistantResponse, isClaudeTurnSlotReserved, readClaudeFinalDeliveryResume, releaseClaudeTurnSlot, runClaudeExhaustedFinalDelivery, shouldApplyClaudeEchoedSessionState, shouldReleaseClaudeTurnSlot, isClaudeMainTurnMessage, openClaudeTurn, planClaudeAssistantTrail, planClaudeStreamingWrite, planClaudeToolCallStart, planClaudeToolProgress, planClaudeToolResults, claudeFinalTurnTrail, publishedClaudeTurnState, rememberDispatchedClaudeInput, resetClaudeTurnActivityState, shouldOpenClaudeTurnOnRunning, shouldStopTypingDotsOnStreamedText, isOpenClaudeTurnState, takeClaudeResultOwner, takeClaudeToolBlockIdByIndex, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeTurnFinal, describeUndeliveredClaudeFinal, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, shouldRouteClaudeTurnArtifacts, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
|
|
38
|
+
import { boundClaudeFinalMetadata, buildClaudeFinalChunkingOptions, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudePendingFinalDelivery, buildClaudeTurnFailureNotice, buildTruncatedFinalText, buildUndeliverableFinalNotice, canDrainClaudeQueuedInput, beginClaudeAssistantResponse, claimClaudeTextSegmentId, claudeFinalWillChunk, classifyFinalDeliveryFailure, claudeInputOwnsTurnSlot, createClaudeTurnActivityState, decideClaudeControlSignalAction, decideClaudeInboundDispatch, dispatchClaudeInput, endClaudeAssistantResponse, isClaudeTurnSlotReserved, readClaudeFinalDeliveryResume, releaseClaudeTurnSlot, runClaudeExhaustedFinalDelivery, shouldApplyClaudeEchoedSessionState, shouldReleaseClaudeTurnSlot, isClaudeMainTurnMessage, openClaudeTurn, planClaudeAssistantTrail, planClaudeStreamingWrite, planClaudeToolCallStart, planClaudeToolProgress, planClaudeToolResults, claudeFinalTurnTrail, publishedClaudeTurnState, rememberDispatchedClaudeInput, resetClaudeTurnActivityState, shouldOpenClaudeTurnOnRunning, shouldStopTypingDotsOnStreamedText, isOpenClaudeTurnState, takeClaudeResultOwner, claudeCompletedSourceMessageIds, shouldFailClaudeSessionForUnattributedResult, takeClaudeToolBlockIdByIndex, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeTurnFinal, describeUndeliveredClaudeFinal, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, shouldRouteClaudeTurnArtifacts, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
|
|
39
39
|
import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } from './user-dialog.js';
|
|
40
40
|
import { isProactiveCommunicationEnabled } from './communication-policy.js';
|
|
41
41
|
function parseRuntimeVisibilityPreset(value) {
|
|
@@ -1059,9 +1059,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1059
1059
|
return streamingOutput.clear().catch(() => { });
|
|
1060
1060
|
}
|
|
1061
1061
|
function markInputCompleted(input) {
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1062
|
+
for (const sourceMessageId of claudeCompletedSourceMessageIds(input)) {
|
|
1063
|
+
config.onInputCompleted?.(sourceMessageId);
|
|
1064
|
+
}
|
|
1065
1065
|
}
|
|
1066
1066
|
/**
|
|
1067
1067
|
* The turn trail to attach to the final send. Empty on a quiet turn — see
|
|
@@ -1120,6 +1120,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1120
1120
|
drainPendingInput();
|
|
1121
1121
|
}
|
|
1122
1122
|
function scheduleFinalHandoffReset() {
|
|
1123
|
+
if (session.closed)
|
|
1124
|
+
return;
|
|
1123
1125
|
clearIdleResetTimer();
|
|
1124
1126
|
session.idleResetTimer = setTimeout(() => {
|
|
1125
1127
|
session.idleResetTimer = null;
|
|
@@ -1177,8 +1179,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1177
1179
|
session.finalizedTurnKeys.add(turn.turnKey);
|
|
1178
1180
|
markInputCompleted(turn);
|
|
1179
1181
|
releaseTurnSlotFor(turn);
|
|
1180
|
-
session.pendingFinalDelivery
|
|
1181
|
-
|
|
1182
|
+
if (session.pendingFinalDelivery?.turnKey === turn.turnKey) {
|
|
1183
|
+
session.pendingFinalDelivery = null;
|
|
1184
|
+
clearFinalDeliveryTimer();
|
|
1185
|
+
}
|
|
1182
1186
|
writeState();
|
|
1183
1187
|
}
|
|
1184
1188
|
/**
|
|
@@ -1461,6 +1465,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1461
1465
|
}, FINAL_DELIVERY_RETRY_MS);
|
|
1462
1466
|
}
|
|
1463
1467
|
function writeState() {
|
|
1468
|
+
if (session.closed)
|
|
1469
|
+
return;
|
|
1464
1470
|
runtimeState.writeSessionState(conversationId, {
|
|
1465
1471
|
contextUsage: session.state.contextUsage,
|
|
1466
1472
|
hostMode: true,
|
|
@@ -1487,6 +1493,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1487
1493
|
writeState();
|
|
1488
1494
|
}
|
|
1489
1495
|
function writeTurn() {
|
|
1496
|
+
if (session.closed)
|
|
1497
|
+
return;
|
|
1490
1498
|
const isOpenTurn = isOpenClaudeTurnState(session.turnState);
|
|
1491
1499
|
if (isOpenTurn) {
|
|
1492
1500
|
session.currentTurnUpdatedAt = Date.now();
|
|
@@ -1635,16 +1643,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1635
1643
|
.updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected')
|
|
1636
1644
|
.catch(() => { });
|
|
1637
1645
|
}
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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
|
-
}
|
|
1646
|
+
void input.inboundHandoff?.release('native-preflight').catch((error) => {
|
|
1647
|
+
console.error('[canon-host] Failed to release native input:', error);
|
|
1648
|
+
});
|
|
1648
1649
|
// resetTurnToIdle releases the slot, tears the published turn down (typing
|
|
1649
1650
|
// signal, seeded /streaming, turn state) and drains the queue. On a closed
|
|
1650
1651
|
// session it is a no-op, which is correct: the teardown owns that path.
|
|
@@ -1654,17 +1655,26 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1654
1655
|
return dispatchClaudeInput(session, input, {
|
|
1655
1656
|
openTurn,
|
|
1656
1657
|
markAccepted: (dispatched) => markQueuedMessageAccepted(dispatched.sourceMessageId, dispatched.markAccepted),
|
|
1657
|
-
prepareArtifacts:
|
|
1658
|
-
|
|
1659
|
-
if (input.kind
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
if (!await engine.claimInbound(id)) {
|
|
1663
|
-
throw new Error('Canon input already has an execution owner.');
|
|
1664
|
-
}
|
|
1658
|
+
prepareArtifacts: prepareArtifactsForInput,
|
|
1659
|
+
send: async (input) => {
|
|
1660
|
+
if (input.kind !== 'canon' || !input.sourceMessageId) {
|
|
1661
|
+
sendInput(input);
|
|
1662
|
+
return;
|
|
1665
1663
|
}
|
|
1664
|
+
const engine = await client.getEndpoint();
|
|
1665
|
+
input.inboundHandoff ??= await engine.acquireInbound(`message:${conversationId}:${input.sourceMessageId}`) ?? undefined;
|
|
1666
|
+
if (!input.inboundHandoff)
|
|
1667
|
+
throw new Error('Canon input already has an execution owner.');
|
|
1668
|
+
if (session.closed)
|
|
1669
|
+
await input.inboundHandoff.release('session-closed');
|
|
1670
|
+
const started = await engine.startInbound(input.inboundHandoff, () => {
|
|
1671
|
+
if (session.dispatchingInput === input)
|
|
1672
|
+
session.dispatchingInput = null;
|
|
1673
|
+
sendInput(input);
|
|
1674
|
+
});
|
|
1675
|
+
if (started.status !== 'submitted')
|
|
1676
|
+
throw new Error(`Canon input ${started.status} before native dispatch.`);
|
|
1666
1677
|
},
|
|
1667
|
-
send: sendInput,
|
|
1668
1678
|
abandon: abandonDispatchedInput,
|
|
1669
1679
|
});
|
|
1670
1680
|
}
|
|
@@ -1780,6 +1790,29 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1780
1790
|
applyTrailCommands(planClaudeToolResults(session.turnActivity, content));
|
|
1781
1791
|
}
|
|
1782
1792
|
// ── Process SDK output messages ──
|
|
1793
|
+
function finishSession() {
|
|
1794
|
+
if (session.closed)
|
|
1795
|
+
return;
|
|
1796
|
+
session.closed = true;
|
|
1797
|
+
inputDone = true;
|
|
1798
|
+
clearIdleResetTimer();
|
|
1799
|
+
clearFinalDeliveryTimer();
|
|
1800
|
+
stopVisibleWorkSignal();
|
|
1801
|
+
try {
|
|
1802
|
+
q.close();
|
|
1803
|
+
}
|
|
1804
|
+
catch { }
|
|
1805
|
+
// A native failure cannot prove whether dispatched work ran. Release local
|
|
1806
|
+
// in-flight claims without completing endpoint receipts or replaying work.
|
|
1807
|
+
const unfinished = new Set([
|
|
1808
|
+
session.activeInput, session.dispatchingInput,
|
|
1809
|
+
...session.pendingInputs, ...dispatchedInputs.values(),
|
|
1810
|
+
]);
|
|
1811
|
+
for (const input of unfinished)
|
|
1812
|
+
void input?.inboundHandoff?.release('session-ended').catch(console.error);
|
|
1813
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ended`);
|
|
1814
|
+
onSessionEnd(conversationId, session.sdkSessionId);
|
|
1815
|
+
}
|
|
1783
1816
|
const processMessages = async () => {
|
|
1784
1817
|
try {
|
|
1785
1818
|
for await (const msg of q) {
|
|
@@ -1988,10 +2021,17 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1988
2021
|
const resultSessionId = msg.session_id;
|
|
1989
2022
|
if (resultSessionId)
|
|
1990
2023
|
session.sdkSessionId = resultSessionId;
|
|
1991
|
-
//
|
|
1992
|
-
//
|
|
1993
|
-
//
|
|
1994
|
-
const completedInput = takeClaudeResultOwner(dispatchedInputs, msg.user_message_uuid
|
|
2024
|
+
// A result may acknowledge several coalesced inputs, but never a
|
|
2025
|
+
// queued input absent from its UUID list. Native background/resume
|
|
2026
|
+
// results with no known UUID do not belong to the active Canon turn.
|
|
2027
|
+
const completedInput = takeClaudeResultOwner(dispatchedInputs, msg.user_message_uuid, msg.user_message_uuids);
|
|
2028
|
+
if (!completedInput) {
|
|
2029
|
+
if (shouldFailClaudeSessionForUnattributedResult(msg, Boolean(session.activeInput || session.dispatchingInput || session.pendingInputs.length))) {
|
|
2030
|
+
throw new Error('Claude returned an unattributed result; native session closed with unfinished inputs preserved.');
|
|
2031
|
+
}
|
|
2032
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring result without a known input UUID`);
|
|
2033
|
+
break;
|
|
2034
|
+
}
|
|
1995
2035
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
|
|
1996
2036
|
const resultText = typeof msg.result === 'string'
|
|
1997
2037
|
? msg.result.trim()
|
|
@@ -2014,7 +2054,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2014
2054
|
// the artifacts still POST first, ahead of the reply they belong to.
|
|
2015
2055
|
const turnFinal = composeClaudeTurnFinal({
|
|
2016
2056
|
resultText,
|
|
2017
|
-
streamedText: session.
|
|
2057
|
+
streamedText: completedInput.turnKey === session.activeInput?.turnKey
|
|
2058
|
+
? session.pendingFinalText : null,
|
|
2018
2059
|
failureNotice,
|
|
2019
2060
|
silenced,
|
|
2020
2061
|
});
|
|
@@ -2077,6 +2118,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2077
2118
|
if (!finalText || !shouldDeliverFinal) {
|
|
2078
2119
|
markInputCompleted(completedInput);
|
|
2079
2120
|
}
|
|
2121
|
+
// Sending the final and fetching usage both await. A newer turn
|
|
2122
|
+
// may now own the live text, pending delivery and handoff timer.
|
|
2123
|
+
if (!shouldReleaseClaudeTurnSlot({
|
|
2124
|
+
dispatching: session.dispatchingInput !== null,
|
|
2125
|
+
activeTurnKey: session.activeInput?.turnKey ?? null,
|
|
2126
|
+
completedTurnKey: completedInput.turnKey,
|
|
2127
|
+
}))
|
|
2128
|
+
break;
|
|
2080
2129
|
if (completedInput?.turnKey === session.activeInput?.turnKey) {
|
|
2081
2130
|
session.activeInput = null;
|
|
2082
2131
|
}
|
|
@@ -2118,16 +2167,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2118
2167
|
catch (err) {
|
|
2119
2168
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Query stream error:`, err);
|
|
2120
2169
|
}
|
|
2121
|
-
|
|
2122
|
-
if (!session.closed) {
|
|
2123
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ended`);
|
|
2124
|
-
onSessionEnd(conversationId, session.sdkSessionId);
|
|
2125
|
-
}
|
|
2170
|
+
finishSession();
|
|
2126
2171
|
};
|
|
2127
2172
|
// Start processing loop and initialize session
|
|
2128
2173
|
const init = async () => {
|
|
2129
2174
|
processMessages().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] processMessages crashed:`, err));
|
|
2130
2175
|
await q.initializationResult();
|
|
2176
|
+
if (session.closed)
|
|
2177
|
+
return;
|
|
2131
2178
|
if (config.model) {
|
|
2132
2179
|
try {
|
|
2133
2180
|
await q.setModel(config.model);
|
|
@@ -2160,6 +2207,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2160
2207
|
const models = await q.supportedModels();
|
|
2161
2208
|
const discoveredOptions = toModelOptions(models);
|
|
2162
2209
|
const supplemental = await detectSupplementalRuntimeModels(cwd, deriveClaudeSupplementalModelProbes(discoveredOptions));
|
|
2210
|
+
if (session.closed)
|
|
2211
|
+
return;
|
|
2163
2212
|
const modelList = resolveClaudeModelOptions({
|
|
2164
2213
|
discovered: mergeClaudeDiscoveredModelOptions(discoveredOptions, ...supplemental),
|
|
2165
2214
|
selectedModel: session.state.model,
|
|
@@ -2194,10 +2243,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2194
2243
|
};
|
|
2195
2244
|
init().catch((err) => {
|
|
2196
2245
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Init failed:`, err);
|
|
2197
|
-
if (resumeSessionId)
|
|
2246
|
+
if (resumeSessionId)
|
|
2198
2247
|
session.sdkSessionId = null;
|
|
2199
|
-
|
|
2200
|
-
}
|
|
2248
|
+
finishSession();
|
|
2201
2249
|
});
|
|
2202
2250
|
return session;
|
|
2203
2251
|
}
|
|
@@ -2642,6 +2690,9 @@ export async function main() {
|
|
|
2642
2690
|
return;
|
|
2643
2691
|
const saveResume = options.saveResume !== false;
|
|
2644
2692
|
session.closed = true;
|
|
2693
|
+
for (const input of [session.dispatchingInput, ...session.pendingInputs]) {
|
|
2694
|
+
void input?.inboundHandoff?.release('session-closed').catch(console.error);
|
|
2695
|
+
}
|
|
2645
2696
|
if (session.typingKeepaliveTimer) {
|
|
2646
2697
|
clearInterval(session.typingKeepaliveTimer);
|
|
2647
2698
|
session.typingKeepaliveTimer = null;
|
|
@@ -2687,6 +2738,11 @@ export async function main() {
|
|
|
2687
2738
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${options.reason ?? (saveResume ? 'suspended (sessionId saved for resume)' : 'cleared')}`);
|
|
2688
2739
|
}
|
|
2689
2740
|
async function resetRuntimeSession(session) {
|
|
2741
|
+
const dispatching = session.dispatchingInput;
|
|
2742
|
+
void dispatching?.inboundHandoff?.cancel('canceled-before-execution').then((canceled) => {
|
|
2743
|
+
if (canceled)
|
|
2744
|
+
session.markInputCompleted(dispatching);
|
|
2745
|
+
}).catch(console.error);
|
|
2690
2746
|
const conversationId = session.conversationId;
|
|
2691
2747
|
// `dispatchingInput` excludes the dispatch window: the slot is reserved but
|
|
2692
2748
|
// the SDK has nothing in flight, so there is nothing to interrupt. The
|
|
@@ -2711,8 +2767,10 @@ export async function main() {
|
|
|
2711
2767
|
const droppedInputs = session.pendingInputs.splice(0);
|
|
2712
2768
|
await markSessionPendingInputsRejected(conversationId, droppedInputs);
|
|
2713
2769
|
session.markInputCompleted(session.activeInput);
|
|
2714
|
-
for (const input of droppedInputs)
|
|
2715
|
-
|
|
2770
|
+
for (const input of droppedInputs) {
|
|
2771
|
+
if (!input.inboundHandoff || input.inboundHandoff.phase === 'settled')
|
|
2772
|
+
session.markInputCompleted(input);
|
|
2773
|
+
}
|
|
2716
2774
|
if (session.activeInput) {
|
|
2717
2775
|
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2718
2776
|
}
|
|
@@ -2724,13 +2782,14 @@ export async function main() {
|
|
|
2724
2782
|
closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
|
|
2725
2783
|
}
|
|
2726
2784
|
async function markSessionPendingInputsRejected(conversationId, inputs) {
|
|
2727
|
-
await Promise.all(inputs.map((input) => {
|
|
2785
|
+
await Promise.all(inputs.map(async (input) => {
|
|
2786
|
+
await input.inboundHandoff?.cancel('canceled-before-execution');
|
|
2728
2787
|
if (!input.markAccepted || !input.sourceMessageId)
|
|
2729
2788
|
return Promise.resolve();
|
|
2730
2789
|
return client.updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected').catch(() => { });
|
|
2731
2790
|
}));
|
|
2732
2791
|
}
|
|
2733
|
-
function removeQueuedInput(conversationId, sourceMessageId) {
|
|
2792
|
+
async function removeQueuedInput(conversationId, sourceMessageId) {
|
|
2734
2793
|
const session = sessions.get(conversationId);
|
|
2735
2794
|
if (!session || session.pendingInputs.length === 0)
|
|
2736
2795
|
return;
|
|
@@ -2738,8 +2797,10 @@ export async function main() {
|
|
|
2738
2797
|
if (removed.length === 0)
|
|
2739
2798
|
return;
|
|
2740
2799
|
session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
|
|
2741
|
-
|
|
2742
|
-
|
|
2800
|
+
await Promise.all(removed.map(async (input) => {
|
|
2801
|
+
if (!input.inboundHandoff || await input.inboundHandoff.cancel('message-deleted'))
|
|
2802
|
+
session.markInputCompleted(input);
|
|
2803
|
+
}));
|
|
2743
2804
|
session.writeTurn();
|
|
2744
2805
|
}
|
|
2745
2806
|
function evictOldestIdle() {
|
|
@@ -2840,12 +2901,6 @@ export async function main() {
|
|
|
2840
2901
|
}
|
|
2841
2902
|
}
|
|
2842
2903
|
existing?.clearStreaming().catch(() => { });
|
|
2843
|
-
if (existing) {
|
|
2844
|
-
settleInboundMessageId(existing.activeInput?.sourceMessageId, false);
|
|
2845
|
-
for (const input of existing.pendingInputs) {
|
|
2846
|
-
settleInboundMessageId(input.sourceMessageId, false);
|
|
2847
|
-
}
|
|
2848
|
-
}
|
|
2849
2904
|
typingSignals.clear(convoId).catch(() => { });
|
|
2850
2905
|
typingSignals.dispose(convoId);
|
|
2851
2906
|
sessions.delete(convoId);
|
|
@@ -2876,14 +2931,13 @@ export async function main() {
|
|
|
2876
2931
|
return roster ? buildCompactGroupContextLines(roster, 'initial').join('\n') : '';
|
|
2877
2932
|
},
|
|
2878
2933
|
currentArtifactAudience: () => ({ memberIds: conversationCache.get(conversationId)?.memberIds, agentId, ownerId }),
|
|
2879
|
-
onInputDeferred: (sourceMessageId) => settleInboundMessageId(sourceMessageId, false),
|
|
2880
2934
|
onInputCompleted: (sourceMessageId) => {
|
|
2881
|
-
recoveryCheckpointsFor(conversationId).settle(sourceMessageId);
|
|
2882
|
-
settleInboundMessageId(sourceMessageId, true);
|
|
2883
2935
|
const id = `message:${conversationId}:${sourceMessageId}`;
|
|
2884
|
-
void endpoint.getInbound(id).then((entry) =>
|
|
2885
|
-
|
|
2886
|
-
|
|
2936
|
+
void endpoint.getInbound(id).then(async (entry) => {
|
|
2937
|
+
if (entry)
|
|
2938
|
+
await endpoint.setInboundState(id, 'settled', 'native-completed');
|
|
2939
|
+
recoveryCheckpointsFor(conversationId).settle(sourceMessageId);
|
|
2940
|
+
}).catch((error) => console.error('[canon-host] Failed to settle native input:', error));
|
|
2887
2941
|
},
|
|
2888
2942
|
publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
|
|
2889
2943
|
}, resumeId);
|
|
@@ -2908,30 +2962,6 @@ export async function main() {
|
|
|
2908
2962
|
pendingSessionCreations.delete(conversationId);
|
|
2909
2963
|
}
|
|
2910
2964
|
}
|
|
2911
|
-
const acceptedInboundMessageIds = new Set();
|
|
2912
|
-
const inFlightInboundMessageIds = new Set();
|
|
2913
|
-
function claimInboundMessageId(messageId) {
|
|
2914
|
-
if (!messageId)
|
|
2915
|
-
return true;
|
|
2916
|
-
if (acceptedInboundMessageIds.has(messageId) || inFlightInboundMessageIds.has(messageId))
|
|
2917
|
-
return false;
|
|
2918
|
-
inFlightInboundMessageIds.add(messageId);
|
|
2919
|
-
return true;
|
|
2920
|
-
}
|
|
2921
|
-
function settleInboundMessageId(messageId, accepted) {
|
|
2922
|
-
if (!messageId)
|
|
2923
|
-
return;
|
|
2924
|
-
inFlightInboundMessageIds.delete(messageId);
|
|
2925
|
-
if (!accepted)
|
|
2926
|
-
return;
|
|
2927
|
-
acceptedInboundMessageIds.add(messageId);
|
|
2928
|
-
while (acceptedInboundMessageIds.size > 2_048) {
|
|
2929
|
-
const oldest = acceptedInboundMessageIds.values().next().value;
|
|
2930
|
-
if (!oldest)
|
|
2931
|
-
break;
|
|
2932
|
-
acceptedInboundMessageIds.delete(oldest);
|
|
2933
|
-
}
|
|
2934
|
-
}
|
|
2935
2965
|
function handleRuntimeReplyMessage(conversationId, message) {
|
|
2936
2966
|
if (message.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
|
|
2937
2967
|
const consumed = approvalManager?.handleMessage(conversationId, {
|
|
@@ -2956,13 +2986,6 @@ export async function main() {
|
|
|
2956
2986
|
}
|
|
2957
2987
|
return consumed;
|
|
2958
2988
|
}
|
|
2959
|
-
function persistedConversationCursor(conversationId) {
|
|
2960
|
-
return loadRuntimeSessionState(runtimeId, {
|
|
2961
|
-
conversationId,
|
|
2962
|
-
baseCwd: workingDir,
|
|
2963
|
-
workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
|
|
2964
|
-
})?.lastInboundMessageId ?? null;
|
|
2965
|
-
}
|
|
2966
2989
|
function persistConversationRecoveryCursor(conversationId, messageId) {
|
|
2967
2990
|
try {
|
|
2968
2991
|
saveRuntimeSessionState(runtimeId, {
|
|
@@ -2993,23 +3016,24 @@ export async function main() {
|
|
|
2993
3016
|
return;
|
|
2994
3017
|
}
|
|
2995
3018
|
const convoId = payload.conversationId;
|
|
2996
|
-
|
|
3019
|
+
const handoff = await endpoint.acquireInbound(event.id);
|
|
3020
|
+
if (!handoff)
|
|
2997
3021
|
return;
|
|
2998
3022
|
recoveryCheckpointsFor(convoId).track(m.id);
|
|
2999
3023
|
knownConversationIds.add(convoId);
|
|
3000
3024
|
if (handleRuntimeReplyMessage(convoId, m)) {
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3025
|
+
if (await handoff.settle('not-dispatched')) {
|
|
3026
|
+
recoveryCheckpointsFor(convoId).settle(m.id);
|
|
3027
|
+
}
|
|
3004
3028
|
return;
|
|
3005
3029
|
}
|
|
3006
3030
|
const sender = m.senderName || m.senderId;
|
|
3007
3031
|
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
3008
3032
|
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
3009
3033
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3034
|
+
if (await handoff.settle('not-dispatched')) {
|
|
3035
|
+
recoveryCheckpointsFor(convoId).settle(m.id);
|
|
3036
|
+
}
|
|
3013
3037
|
return;
|
|
3014
3038
|
}
|
|
3015
3039
|
await (async () => {
|
|
@@ -3109,6 +3133,7 @@ export async function main() {
|
|
|
3109
3133
|
materialized: [...replyMedia.materialized, ...materialized],
|
|
3110
3134
|
});
|
|
3111
3135
|
const turnModes = {
|
|
3136
|
+
inboundHandoff: handoff,
|
|
3112
3137
|
...resolveClaudeTurnModes(participantContext),
|
|
3113
3138
|
commandContext: commandInput.commandContext,
|
|
3114
3139
|
replyAuthority: payload.replyAuthority ?? null,
|
|
@@ -3129,12 +3154,12 @@ export async function main() {
|
|
|
3129
3154
|
return true;
|
|
3130
3155
|
})().then(async (queued) => {
|
|
3131
3156
|
if (!queued) {
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3157
|
+
if (await handoff.settle('not-dispatched')) {
|
|
3158
|
+
recoveryCheckpointsFor(convoId).settle(m.id);
|
|
3159
|
+
}
|
|
3135
3160
|
}
|
|
3136
|
-
}).catch((error) => {
|
|
3137
|
-
|
|
3161
|
+
}).catch(async (error) => {
|
|
3162
|
+
await handoff.release('queue-failed');
|
|
3138
3163
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
|
|
3139
3164
|
throw error;
|
|
3140
3165
|
});
|
|
@@ -3151,7 +3176,7 @@ export async function main() {
|
|
|
3151
3176
|
data: payload });
|
|
3152
3177
|
},
|
|
3153
3178
|
onMessageDeleted: (payload) => {
|
|
3154
|
-
removeQueuedInput(payload.conversationId, payload.messageId);
|
|
3179
|
+
void removeQueuedInput(payload.conversationId, payload.messageId).catch(console.error);
|
|
3155
3180
|
},
|
|
3156
3181
|
onConversationUpdated: (payload) => {
|
|
3157
3182
|
handleConversationUpdated(payload);
|
|
@@ -3209,6 +3234,22 @@ export async function main() {
|
|
|
3209
3234
|
queueDepth: session.pendingInputs.length,
|
|
3210
3235
|
});
|
|
3211
3236
|
if (action === 'defer') {
|
|
3237
|
+
const dispatching = session.dispatchingInput;
|
|
3238
|
+
if (dispatching?.inboundHandoff) {
|
|
3239
|
+
// Stop owns the pre-submit window too. Register cancellation before
|
|
3240
|
+
// any queued-disposition write; the shared claim fence prevents send.
|
|
3241
|
+
const canceled = dispatching.inboundHandoff.cancel('canceled-before-execution');
|
|
3242
|
+
const dropped = event.type === 'stop_and_drop' ? session.pendingInputs.splice(0) : [];
|
|
3243
|
+
await markSessionPendingInputsRejected(convoId, dropped);
|
|
3244
|
+
for (const input of dropped) {
|
|
3245
|
+
if (!input.inboundHandoff || input.inboundHandoff.phase === 'settled')
|
|
3246
|
+
session.markInputCompleted(input);
|
|
3247
|
+
}
|
|
3248
|
+
if (await canceled) {
|
|
3249
|
+
session.markInputCompleted(dispatching);
|
|
3250
|
+
return;
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3212
3253
|
// Reserved but not yet dispatched: there is nothing in the SDK to stop
|
|
3213
3254
|
// YET. Consuming the signal here would make a Stop pressed in the
|
|
3214
3255
|
// accept/artifact window do nothing while the turn it was aimed at ran to
|
|
@@ -3240,8 +3281,10 @@ export async function main() {
|
|
|
3240
3281
|
await markSessionPendingInputsRejected(convoId, droppedInputs);
|
|
3241
3282
|
}
|
|
3242
3283
|
session.markInputCompleted(session.activeInput);
|
|
3243
|
-
for (const input of droppedInputs)
|
|
3244
|
-
|
|
3284
|
+
for (const input of droppedInputs) {
|
|
3285
|
+
if (!input.inboundHandoff || input.inboundHandoff.phase === 'settled')
|
|
3286
|
+
session.markInputCompleted(input);
|
|
3287
|
+
}
|
|
3245
3288
|
if (session.activeInput) {
|
|
3246
3289
|
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
3247
3290
|
}
|
package/dist/session-state.d.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import type { SDKMessageOrigin, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
1
|
+
import type { SDKMessageOrigin, SDKResultMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
2
2
|
import type { DeliveryIntent, ModelOption, RuntimeStreamingPayload, TurnLifecycleState, TurnOutputBlock, TurnOutputSnapshot, TurnVerbosity } from '@canonmsg/core';
|
|
3
|
+
import type { EndpointInboundHandoff } from '@canonmsg/core';
|
|
3
4
|
import type { AgentReplyAuthorityV1 } from '@canonmsg/backend-contracts';
|
|
4
5
|
import type { TurnArtifactRoutingDecision, TurnArtifactSnapshot } from '@canonmsg/coding-agent-host';
|
|
5
6
|
export type ClaudeInputKind = 'seed' | 'canon';
|
|
6
7
|
export type ClaudeArtifactRoutingMode = 'workspace-generated' | 'disabled';
|
|
7
8
|
export interface ClaudeInputEnvelope {
|
|
9
|
+
inboundHandoff?: EndpointInboundHandoff;
|
|
8
10
|
commandContext?: string;
|
|
9
11
|
kind: ClaudeInputKind;
|
|
10
|
-
/** Correlates this envelope with its turn result's
|
|
12
|
+
/** Correlates this envelope with its turn result's user-message UUIDs. */
|
|
11
13
|
messageUuid: string;
|
|
14
|
+
/** Other Canon inputs acknowledged by the same result; settle with its final. */
|
|
15
|
+
resultSourceMessageIds?: string[];
|
|
12
16
|
msg: SDKUserMessage;
|
|
13
17
|
intent: DeliveryIntent;
|
|
14
18
|
sourceMessageId: string | null;
|
|
@@ -33,6 +37,7 @@ export interface ClaudeInputEnvelope {
|
|
|
33
37
|
* grouped so they travel together from the enqueue site onto the envelope.
|
|
34
38
|
*/
|
|
35
39
|
export interface ClaudeTurnModes {
|
|
40
|
+
inboundHandoff?: EndpointInboundHandoff;
|
|
36
41
|
commandContext?: string;
|
|
37
42
|
artifactRoutingMode?: ClaudeArtifactRoutingMode;
|
|
38
43
|
turnVerbosity?: TurnVerbosity;
|
|
@@ -98,6 +103,7 @@ export interface ClaudeCompletedTurnState {
|
|
|
98
103
|
turnActivity?: ClaudeTurnActivityState;
|
|
99
104
|
}
|
|
100
105
|
export declare function createClaudeInputEnvelope(input: {
|
|
106
|
+
inboundHandoff?: EndpointInboundHandoff;
|
|
101
107
|
commandContext?: string;
|
|
102
108
|
kind: ClaudeInputKind;
|
|
103
109
|
msg: SDKUserMessage;
|
|
@@ -150,15 +156,24 @@ export declare function shouldRouteClaudeTurnArtifacts(input: {
|
|
|
150
156
|
}): TurnArtifactRoutingDecision;
|
|
151
157
|
export declare function rememberDispatchedClaudeInput(dispatched: Map<string, ClaudeInputEnvelope>, input: ClaudeInputEnvelope): void;
|
|
152
158
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* The
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
|
|
159
|
-
|
|
159
|
+
* Match only the inputs the SDK explicitly acknowledges. Its plural list is in
|
|
160
|
+
* consumption order and can include messages folded in after the first reply.
|
|
161
|
+
* The last known Canon input owns the final; internal seeds cannot displace it.
|
|
162
|
+
* A singular UUID acknowledges only itself, never everything earlier in our map.
|
|
163
|
+
* Missing/unknown attribution must not fall back to an unrelated active turn.
|
|
164
|
+
*/
|
|
165
|
+
export declare function takeClaudeResultOwner(dispatched: Map<string, ClaudeInputEnvelope>, userMessageUuid: unknown, userMessageUuids?: unknown): ClaudeInputEnvelope | null;
|
|
166
|
+
/** Keep coalesced inputs pending until their shared final is delivered or retired. */
|
|
167
|
+
export declare function claudeCompletedSourceMessageIds(input: ClaudeInputEnvelope | null | undefined): string[];
|
|
168
|
+
/**
|
|
169
|
+
* A zeroed/session error may have no input UUID. Treat it as a session failure,
|
|
170
|
+
* never as successful completion of whichever Canon input happens to be active.
|
|
171
|
+
* An unattributed success while foreground work is outstanding is ambiguous too:
|
|
172
|
+
* waiting for an idle echo would wedge that input, because its slot rejects idle
|
|
173
|
+
* until completion. Close the native session and preserve the unsettled input.
|
|
174
|
+
* Explicit foreign/replayed UUIDs remain unrelated results and are ignored.
|
|
160
175
|
*/
|
|
161
|
-
export declare function
|
|
176
|
+
export declare function shouldFailClaudeSessionForUnattributedResult(result: Pick<SDKResultMessage, 'subtype' | 'is_error' | 'user_message_uuid' | 'user_message_uuids'>, hasForegroundWork: boolean): boolean;
|
|
162
177
|
/**
|
|
163
178
|
* Whether an envelope owns the session's active-turn slot. Only Canon turns do;
|
|
164
179
|
* any future internal envelope must remain invisible to user-facing turn state.
|
|
@@ -304,7 +319,7 @@ export interface ClaudeDispatchDeps {
|
|
|
304
319
|
/** Capture the artifact baseline — a workspace walk. */
|
|
305
320
|
prepareArtifacts: (input: ClaudeInputEnvelope) => Promise<void>;
|
|
306
321
|
/** Hand the input to the SDK. */
|
|
307
|
-
send: (input: ClaudeInputEnvelope) => void
|
|
322
|
+
send: (input: ClaudeInputEnvelope) => void | Promise<void>;
|
|
308
323
|
/** Settle an input that will never be answered, and retire its turn. */
|
|
309
324
|
abandon: (input: ClaudeInputEnvelope, reason: ClaudeDispatchAbandonReason, error?: unknown) => void;
|
|
310
325
|
}
|
package/dist/session-state.js
CHANGED
|
@@ -12,6 +12,7 @@ export function createClaudeInputEnvelope(input) {
|
|
|
12
12
|
return {
|
|
13
13
|
...(input.commandContext ? { commandContext: input.commandContext } : {}),
|
|
14
14
|
kind: input.kind,
|
|
15
|
+
...(input.inboundHandoff ? { inboundHandoff: input.inboundHandoff } : {}),
|
|
15
16
|
messageUuid,
|
|
16
17
|
msg: { ...input.msg, uuid: messageUuid },
|
|
17
18
|
intent: input.intent ?? 'queue',
|
|
@@ -99,28 +100,54 @@ export function rememberDispatchedClaudeInput(dispatched, input) {
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* The
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* folded into that batch do not linger. Returns null when the result carries no
|
|
108
|
-
* usable uuid, leaving the caller to fall back.
|
|
103
|
+
* Match only the inputs the SDK explicitly acknowledges. Its plural list is in
|
|
104
|
+
* consumption order and can include messages folded in after the first reply.
|
|
105
|
+
* The last known Canon input owns the final; internal seeds cannot displace it.
|
|
106
|
+
* A singular UUID acknowledges only itself, never everything earlier in our map.
|
|
107
|
+
* Missing/unknown attribution must not fall back to an unrelated active turn.
|
|
109
108
|
*/
|
|
110
|
-
export function takeClaudeResultOwner(dispatched, userMessageUuid) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
dispatched.delete(key);
|
|
117
|
-
if (key === userMessageUuid) {
|
|
118
|
-
owner = envelope;
|
|
119
|
-
break;
|
|
120
|
-
}
|
|
109
|
+
export function takeClaudeResultOwner(dispatched, userMessageUuid, userMessageUuids) {
|
|
110
|
+
const uuids = Array.isArray(userMessageUuids)
|
|
111
|
+
? userMessageUuids.filter((uuid) => typeof uuid === 'string' && uuid.length > 0)
|
|
112
|
+
: [];
|
|
113
|
+
if (typeof userMessageUuid === 'string' && !uuids.includes(userMessageUuid)) {
|
|
114
|
+
uuids.push(userMessageUuid);
|
|
121
115
|
}
|
|
116
|
+
const inputs = [...new Set(uuids)].flatMap((uuid) => {
|
|
117
|
+
const input = dispatched.get(uuid);
|
|
118
|
+
return input ? [input] : [];
|
|
119
|
+
});
|
|
120
|
+
const owner = [...inputs].reverse().find((input) => input.kind === 'canon') ?? inputs.at(-1) ?? null;
|
|
121
|
+
if (!owner)
|
|
122
|
+
return null;
|
|
123
|
+
owner.resultSourceMessageIds = [...new Set(inputs.flatMap(claudeCompletedSourceMessageIds))];
|
|
124
|
+
for (const input of inputs)
|
|
125
|
+
dispatched.delete(input.messageUuid);
|
|
122
126
|
return owner;
|
|
123
127
|
}
|
|
128
|
+
/** Keep coalesced inputs pending until their shared final is delivered or retired. */
|
|
129
|
+
export function claudeCompletedSourceMessageIds(input) {
|
|
130
|
+
if (input?.kind !== 'canon')
|
|
131
|
+
return [];
|
|
132
|
+
return [...new Set([
|
|
133
|
+
...(input.sourceMessageId ? [input.sourceMessageId] : []),
|
|
134
|
+
...(input.resultSourceMessageIds ?? []),
|
|
135
|
+
])];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* A zeroed/session error may have no input UUID. Treat it as a session failure,
|
|
139
|
+
* never as successful completion of whichever Canon input happens to be active.
|
|
140
|
+
* An unattributed success while foreground work is outstanding is ambiguous too:
|
|
141
|
+
* waiting for an idle echo would wedge that input, because its slot rejects idle
|
|
142
|
+
* until completion. Close the native session and preserve the unsettled input.
|
|
143
|
+
* Explicit foreign/replayed UUIDs remain unrelated results and are ignored.
|
|
144
|
+
*/
|
|
145
|
+
export function shouldFailClaudeSessionForUnattributedResult(result, hasForegroundWork) {
|
|
146
|
+
const hasUuid = (typeof result.user_message_uuid === 'string' && result.user_message_uuid.length > 0)
|
|
147
|
+
|| (Array.isArray(result.user_message_uuids)
|
|
148
|
+
&& result.user_message_uuids.some((uuid) => typeof uuid === 'string' && uuid.length > 0));
|
|
149
|
+
return !hasUuid && (hasForegroundWork || result.is_error || result.subtype !== 'success');
|
|
150
|
+
}
|
|
124
151
|
/**
|
|
125
152
|
* Whether an envelope owns the session's active-turn slot. Only Canon turns do;
|
|
126
153
|
* any future internal envelope must remain invisible to user-facing turn state.
|
|
@@ -305,17 +332,30 @@ export async function dispatchClaudeInput(session, input, deps) {
|
|
|
305
332
|
catch (error) {
|
|
306
333
|
failure = { error };
|
|
307
334
|
}
|
|
308
|
-
if (session.dispatchingInput === input)
|
|
309
|
-
session.dispatchingInput = null;
|
|
310
335
|
if (failure) {
|
|
336
|
+
if (session.dispatchingInput === input)
|
|
337
|
+
session.dispatchingInput = null;
|
|
311
338
|
deps.abandon(input, 'error', failure.error);
|
|
312
339
|
return;
|
|
313
340
|
}
|
|
314
341
|
if (session.closed) {
|
|
342
|
+
if (session.dispatchingInput === input)
|
|
343
|
+
session.dispatchingInput = null;
|
|
315
344
|
deps.abandon(input, 'closed');
|
|
316
345
|
return;
|
|
317
346
|
}
|
|
318
|
-
|
|
347
|
+
try {
|
|
348
|
+
await deps.send(input);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
if (session.dispatchingInput === input)
|
|
352
|
+
session.dispatchingInput = null;
|
|
353
|
+
deps.abandon(input, 'error', error);
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
if (session.dispatchingInput === input)
|
|
357
|
+
session.dispatchingInput = null;
|
|
358
|
+
}
|
|
319
359
|
}
|
|
320
360
|
// ── Turn lifecycle: opening a turn before the SDK says anything ──────
|
|
321
361
|
//
|
|
@@ -1188,12 +1228,9 @@ export async function runClaudeExhaustedFinalDelivery(session, pending, deps) {
|
|
|
1188
1228
|
}
|
|
1189
1229
|
deps.retireTurn();
|
|
1190
1230
|
}
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
1193
|
-
|
|
1194
|
-
// shortens the picker instead of failing. Keep this at or above the CLI the
|
|
1195
|
-
// pinned SDK bundles, so the bundled fallback always satisfies it.
|
|
1196
|
-
export const MINIMUM_CLAUDE_CLI_VERSION = [2, 1, 220];
|
|
1231
|
+
// External CLIs must support the same input/result attribution contract as the
|
|
1232
|
+
// pinned SDK. Older binaries fall back to the bundled, audited runtime.
|
|
1233
|
+
export const MINIMUM_CLAUDE_CLI_VERSION = [2, 1, 278];
|
|
1197
1234
|
export function parseClaudeCliVersion(output) {
|
|
1198
1235
|
const match = output.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
1199
1236
|
return match ? match.slice(1, 4).map(Number) : null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/claude-code-plugin",
|
|
3
|
-
"version": "0.36.
|
|
3
|
+
"version": "0.36.3",
|
|
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",
|
|
@@ -30,11 +30,11 @@
|
|
|
30
30
|
"test": "vitest run"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.
|
|
34
|
-
"@canonmsg/agent-sdk": "^11.0.
|
|
33
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.278",
|
|
34
|
+
"@canonmsg/agent-sdk": "^11.0.2",
|
|
35
35
|
"@canonmsg/agent-tools": "^0.11.0",
|
|
36
|
-
"@canonmsg/coding-agent-host": "^0.
|
|
37
|
-
"@canonmsg/core": "^13.0.
|
|
36
|
+
"@canonmsg/coding-agent-host": "^0.10.0",
|
|
37
|
+
"@canonmsg/core": "^13.0.5",
|
|
38
38
|
"@canonmsg/rich-cards": "^0.10.6",
|
|
39
39
|
"@modelcontextprotocol/sdk": "^1.30.0"
|
|
40
40
|
},
|