@canonmsg/claude-code-plugin 0.28.0 → 0.29.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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/host.js +651 -144
- package/dist/session-state.d.ts +535 -2
- package/dist/session-state.js +879 -7
- package/package.json +4 -4
package/dist/host.js
CHANGED
|
@@ -27,14 +27,14 @@ 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 { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
|
|
30
|
-
import { captureTurnArtifactSnapshot, collectTurnArtifacts, } from '@canonmsg/coding-agent-host';
|
|
31
|
-
import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, readLocalRuntimeEntry, resolveCanonAgent, verifyResolvedAgentEnvironment, initRTDBAuth, sendMessageWithRetry, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
30
|
+
import { captureTurnArtifactSnapshot, collectTurnArtifacts, IDLE_TIMEOUT_MS, } from '@canonmsg/coding-agent-host';
|
|
31
|
+
import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, buildBoundedTurnTrail, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, readLocalRuntimeEntry, resolveCanonAgent, verifyResolvedAgentEnvironment, initRTDBAuth, isChunkedSendMessageError, sendMessageWithRetry, sendMessageWithRetryChunked, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
32
32
|
import { decideAutoReply, } from './inbound-policy.js';
|
|
33
33
|
import { runCli } from './cli-entry.js';
|
|
34
34
|
import { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer } from '@canonmsg/agent-tools';
|
|
35
35
|
import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
|
|
36
36
|
import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
|
|
37
|
-
import { applyClaudeSessionControl, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudeTurnFailureNotice, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeFinalText, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
|
|
37
|
+
import { applyClaudeSessionControl, boundClaudeFinalMetadata, buildClaudeFinalChunkingOptions, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudePendingFinalDelivery, buildClaudeTurnFailureNotice, buildTruncatedFinalText, buildUndeliverableFinalNotice, canDrainClaudeQueuedInput, claudeFinalWillChunk, classifyFinalDeliveryFailure, claudeInputOwnsTurnSlot, createClaudeTurnActivityState, decideClaudeControlSignalAction, decideClaudeInboundDispatch, dispatchClaudeInput, isClaudeTurnSlotReserved, readClaudeFinalDeliveryResume, releaseClaudeTurnSlot, runClaudeExhaustedFinalDelivery, shouldApplyClaudeEchoedSessionState, shouldReleaseClaudeTurnSlot, isClaudeMainTurnMessage, openClaudeTurn, planClaudeAssistantTrail, planClaudeToolCallStart, planClaudeToolProgress, planClaudeToolResults, prepareTurnTrailForDelivery, rememberDispatchedClaudeInput, resetClaudeTurnActivityState, shouldOpenClaudeTurnOnRunning, isOpenClaudeTurnState, takeClaudeResultOwner, takeClaudeToolBlockIdByIndex, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeFinalText, describeUndeliveredClaudeFinal, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
|
|
38
38
|
import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } from './user-dialog.js';
|
|
39
39
|
import { collectMissedInboundMessages, createReconnectRecoveryGate, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
|
|
40
40
|
function parseRuntimeVisibilityPreset(value) {
|
|
@@ -316,10 +316,7 @@ function resolveClaudeCliPath() {
|
|
|
316
316
|
const override = process.env.CANON_CLAUDE_CLI_PATH?.trim();
|
|
317
317
|
if (override) {
|
|
318
318
|
if (existsSync(override)) {
|
|
319
|
-
|
|
320
|
-
cachedClaudeCliPath = override;
|
|
321
|
-
console.error(`[canon-host] claude CLI override: ${override}`);
|
|
322
|
-
return override;
|
|
319
|
+
return acceptClaudeCli('override', override);
|
|
323
320
|
}
|
|
324
321
|
console.error(`[canon-host] CANON_CLAUDE_CLI_PATH=${override} not found; ignoring`);
|
|
325
322
|
}
|
|
@@ -340,41 +337,41 @@ function resolveClaudeCliPath() {
|
|
|
340
337
|
console.error('[canon-host] claude CLI not on PATH; using SDK-bundled binary');
|
|
341
338
|
return undefined;
|
|
342
339
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
340
|
+
return acceptClaudeCli('on PATH', resolved);
|
|
341
|
+
}
|
|
342
|
+
function readClaudeCliVersionSafely(cliPath) {
|
|
346
343
|
try {
|
|
347
|
-
|
|
344
|
+
const output = execFileSync(cliPath, ['--version'], { encoding: 'utf8' }).trim();
|
|
345
|
+
const version = parseClaudeCliVersion(output);
|
|
346
|
+
if (!version) {
|
|
347
|
+
console.error(`[canon-host] Could not determine Claude Code version from: ${output}`);
|
|
348
|
+
}
|
|
349
|
+
return version;
|
|
348
350
|
}
|
|
349
351
|
catch (error) {
|
|
350
|
-
console.error(`[canon-host] Could not
|
|
351
|
-
|
|
352
|
-
if (!version || !isSupportedClaudeCliVersion(version)) {
|
|
353
|
-
cachedClaudeCliPath = null;
|
|
354
|
-
const found = version ? `is ${formatClaudeCliVersion(version)}` : 'has an unreadable version';
|
|
355
|
-
console.error(`[canon-host] claude CLI on PATH (${resolved}) ${found}; `
|
|
356
|
-
+ `${formatClaudeCliVersion(MINIMUM_CLAUDE_CLI_VERSION)} or newer is required — `
|
|
357
|
-
+ 'using the SDK-bundled binary instead. Run `claude update` to use your own install.');
|
|
358
|
-
return undefined;
|
|
359
|
-
}
|
|
360
|
-
cachedClaudeCliPath = resolved;
|
|
361
|
-
console.error(`[canon-host] claude CLI on PATH: ${resolved} (${formatClaudeCliVersion(version)})`);
|
|
362
|
-
return resolved;
|
|
363
|
-
}
|
|
364
|
-
function readExternalClaudeCliVersion(cliPath) {
|
|
365
|
-
const output = execFileSync(cliPath, ['--version'], { encoding: 'utf8' }).trim();
|
|
366
|
-
const version = parseClaudeCliVersion(output);
|
|
367
|
-
if (!version) {
|
|
368
|
-
throw new Error(`Could not determine Claude Code version from: ${output}`);
|
|
352
|
+
console.error(`[canon-host] Could not run ${cliPath} --version:`, error);
|
|
353
|
+
return null;
|
|
369
354
|
}
|
|
370
|
-
return version;
|
|
371
355
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
356
|
+
/**
|
|
357
|
+
* Take a candidate CLI, or fall back to the SDK-bundled binary. Deliberately
|
|
358
|
+
* total: this is reached from the runtime heartbeat, so an unusable CLI must
|
|
359
|
+
* cost a few model options, never take the host down. A stale binary would
|
|
360
|
+
* otherwise shadow the newer bundled one and quietly shorten model discovery.
|
|
361
|
+
*/
|
|
362
|
+
function acceptClaudeCli(source, cliPath) {
|
|
363
|
+
const version = readClaudeCliVersionSafely(cliPath);
|
|
364
|
+
if (version && isSupportedClaudeCliVersion(version)) {
|
|
365
|
+
cachedClaudeCliPath = cliPath;
|
|
366
|
+
console.error(`[canon-host] claude CLI ${source}: ${cliPath} (${formatClaudeCliVersion(version)})`);
|
|
367
|
+
return cliPath;
|
|
368
|
+
}
|
|
369
|
+
cachedClaudeCliPath = null;
|
|
370
|
+
console.error(`[canon-host] claude CLI ${source} (${cliPath}) `
|
|
371
|
+
+ `${version ? `is ${formatClaudeCliVersion(version)}` : 'has an unreadable version'}; `
|
|
372
|
+
+ `${formatClaudeCliVersion(MINIMUM_CLAUDE_CLI_VERSION)} or newer is required — `
|
|
373
|
+
+ 'using the SDK-bundled binary instead. Run `claude update` to use your own install.');
|
|
374
|
+
return undefined;
|
|
378
375
|
}
|
|
379
376
|
function toModelOptions(models) {
|
|
380
377
|
return models.map(claudeModelInfoToOption);
|
|
@@ -680,7 +677,7 @@ function buildClaudeRuntimeInfo(input) {
|
|
|
680
677
|
}
|
|
681
678
|
// ── Constants ───────────────────────────────────────────────────────
|
|
682
679
|
const MAX_SESSIONS = 5;
|
|
683
|
-
|
|
680
|
+
// IDLE_TIMEOUT_MS (30 minutes) is shared with the other coding-agent hosts.
|
|
684
681
|
const STREAMING_THROTTLE_MS = 250;
|
|
685
682
|
const HEARTBEAT_MS = 30_000;
|
|
686
683
|
const IDLE_CHECK_MS = 60_000;
|
|
@@ -688,7 +685,11 @@ const CONTROL_POLL_MS = 2000;
|
|
|
688
685
|
const IDLE_CONTROL_POLL_MS = 10_000;
|
|
689
686
|
const CONTROL_POLL_JITTER_MS = 1_000;
|
|
690
687
|
const FINAL_DELIVERY_RETRY_MS = 30_000;
|
|
691
|
-
|
|
688
|
+
// The timer now only ever re-sends genuinely retryable failures (429/5xx/
|
|
689
|
+
// transport) — permanent rejections are surfaced immediately instead of
|
|
690
|
+
// consuming this budget — so one more attempt buys a longer outage window
|
|
691
|
+
// without delaying anything that was going to fail anyway.
|
|
692
|
+
const MAX_FINAL_DELIVERY_RETRIES = 2;
|
|
692
693
|
const CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID = 'claude-recovery-cursor-v1';
|
|
693
694
|
const CLAUDE_RUNTIME_CAPABILITIES = {
|
|
694
695
|
...DEFAULT_RUNTIME_CAPABILITIES,
|
|
@@ -785,6 +786,22 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
785
786
|
artifactRoutingMode,
|
|
786
787
|
}));
|
|
787
788
|
};
|
|
789
|
+
// Envelopes pulled by the SDK, keyed by the uuid stamped on each message, so a
|
|
790
|
+
// turn result can be matched to its own input. The SDK drains this iterable
|
|
791
|
+
// eagerly and may coalesce queued messages, so neither arrival order nor a
|
|
792
|
+
// single mutable slot can attribute results correctly on their own.
|
|
793
|
+
const dispatchedInputs = new Map();
|
|
794
|
+
// Only Canon turns may own the active-input slot. Result ownership comes from
|
|
795
|
+
// UUID correlation, while this slot drives live tool/dialog state.
|
|
796
|
+
function bindActiveInput(input) {
|
|
797
|
+
rememberDispatchedClaudeInput(dispatchedInputs, input);
|
|
798
|
+
// The slot drives in-turn concerns (tool routing, dialogs, interrupts), so
|
|
799
|
+
// it tracks the newest Canon turn. Result ownership is resolved by uuid, not
|
|
800
|
+
// from here.
|
|
801
|
+
if (!claudeInputOwnsTurnSlot(input))
|
|
802
|
+
return;
|
|
803
|
+
session.activeInput = input;
|
|
804
|
+
}
|
|
788
805
|
const inputStream = {
|
|
789
806
|
[Symbol.asyncIterator]() {
|
|
790
807
|
return {
|
|
@@ -794,12 +811,12 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
794
811
|
// Drain queued messages first (sent before iterator was ready)
|
|
795
812
|
if (messageQueue.length > 0) {
|
|
796
813
|
const next = messageQueue.shift();
|
|
797
|
-
|
|
814
|
+
bindActiveInput(next);
|
|
798
815
|
return Promise.resolve({ done: false, value: next.msg });
|
|
799
816
|
}
|
|
800
817
|
return new Promise((resolve) => {
|
|
801
818
|
resolveInput = (input) => {
|
|
802
|
-
|
|
819
|
+
bindActiveInput(input);
|
|
803
820
|
resolve({ done: false, value: input.msg });
|
|
804
821
|
};
|
|
805
822
|
});
|
|
@@ -1105,9 +1122,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1105
1122
|
streamingTimer: null,
|
|
1106
1123
|
idleResetTimer: null,
|
|
1107
1124
|
finalDeliveryTimer: null,
|
|
1108
|
-
seedResponseHandled: !!resumeSessionId, // Resumed sessions don't need seed handling
|
|
1109
1125
|
pendingInputs: [],
|
|
1110
1126
|
activeInput: null,
|
|
1127
|
+
dispatchingInput: null,
|
|
1111
1128
|
finalizedTurnKeys: new Set(),
|
|
1112
1129
|
interruptedTurnKeys: new Set(),
|
|
1113
1130
|
pendingFinalText: null,
|
|
@@ -1118,6 +1135,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1118
1135
|
currentTurnId: null,
|
|
1119
1136
|
currentTurnOpenedAt: null,
|
|
1120
1137
|
currentTurnUpdatedAt: null,
|
|
1138
|
+
turnActivity: createClaudeTurnActivityState(),
|
|
1121
1139
|
activeSelfContextId: null,
|
|
1122
1140
|
lastAcceptedIntent: null,
|
|
1123
1141
|
lastActivity: Date.now(),
|
|
@@ -1171,6 +1189,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1171
1189
|
return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
|
|
1172
1190
|
}
|
|
1173
1191
|
function clearStreaming() {
|
|
1192
|
+
// The trail bookkeeping only ever points at blocks inside the node being
|
|
1193
|
+
// cleared, so it dies with it. Doing this here covers every caller —
|
|
1194
|
+
// interrupts, session reset, session close — rather than each remembering.
|
|
1195
|
+
resetClaudeTurnActivityState(session.turnActivity);
|
|
1174
1196
|
return streamingOutput.clear().catch(() => { });
|
|
1175
1197
|
}
|
|
1176
1198
|
function markInputCompleted(input) {
|
|
@@ -1191,9 +1213,20 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1191
1213
|
}
|
|
1192
1214
|
config.onInputCompleted?.(input.sourceMessageId);
|
|
1193
1215
|
}
|
|
1194
|
-
|
|
1216
|
+
/**
|
|
1217
|
+
* The turn trail to attach to the final send.
|
|
1218
|
+
*
|
|
1219
|
+
* Filtering runs BEFORE bounding on purpose. `buildBoundedTurnTrail` walks
|
|
1220
|
+
* blocks in sequence order and stops at the first one that would exceed its
|
|
1221
|
+
* byte budget, and Claude's text blocks are the fattest entries — bounding
|
|
1222
|
+
* first would spend the budget on blocks a chunked final then discards,
|
|
1223
|
+
* leaving the margin with fewer tool blocks than it could have shown. With
|
|
1224
|
+
* `willChunk` false this is exactly `getFinalTrail()`.
|
|
1225
|
+
*/
|
|
1226
|
+
function getFinalTurnTrail(willChunk) {
|
|
1195
1227
|
const turnId = session.currentTurnId ?? `claude-${conversationId}`;
|
|
1196
|
-
|
|
1228
|
+
const blocks = prepareTurnTrailForDelivery(streamingOutput.getBlocks(), willChunk);
|
|
1229
|
+
return buildBoundedTurnTrail(blocks).map((block) => ({ ...block, turnId }));
|
|
1197
1230
|
}
|
|
1198
1231
|
function clearIdleResetTimer() {
|
|
1199
1232
|
if (!session.idleResetTimer)
|
|
@@ -1207,10 +1240,31 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1207
1240
|
clearTimeout(session.finalDeliveryTimer);
|
|
1208
1241
|
session.finalDeliveryTimer = null;
|
|
1209
1242
|
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Retire the finished turn and start the next queued one.
|
|
1245
|
+
*
|
|
1246
|
+
* The live node is cleared HERE, unconditionally, rather than only at the
|
|
1247
|
+
* three call sites that remembered to: `drainPendingInput` below opens the
|
|
1248
|
+
* next turn, and a surviving `/streaming` node still carrying the previous
|
|
1249
|
+
* turn's id makes the client drop the new turn's live row entirely
|
|
1250
|
+
* (chat-domain buildSyntheticTurnMessages skips a turn whose streaming turnId
|
|
1251
|
+
* disagrees) — the same silence this PR is closing.
|
|
1252
|
+
*
|
|
1253
|
+
* Clearing `/streaming` fires functions' `onStreamingCleared`, which persists
|
|
1254
|
+
* the streamed text as a "preserved" progress message when the turn has no
|
|
1255
|
+
* `turn_complete`. Doing it universally does NOT widen that: every existing
|
|
1256
|
+
* caller (final handoff, retry exhaustion, and the gated/no-final branch of
|
|
1257
|
+
* the result handler) already clears immediately before calling this, so a
|
|
1258
|
+
* gated turn's node is deleted today exactly as it is here — RTDB fires the
|
|
1259
|
+
* trigger on the delete that had data, and the redundant second delete has
|
|
1260
|
+
* nothing to fire on. The turn-open seed is empty (no text, no blocks), which
|
|
1261
|
+
* the trigger itself skips as `empty_streaming_content`.
|
|
1262
|
+
*/
|
|
1210
1263
|
function resetTurnToIdle() {
|
|
1211
1264
|
if (session.closed)
|
|
1212
1265
|
return;
|
|
1213
1266
|
clearFinalDeliveryTimer();
|
|
1267
|
+
clearStreaming().catch(() => { });
|
|
1214
1268
|
resetClaudeCompletedTurnState(session);
|
|
1215
1269
|
stopVisibleWorkSignal();
|
|
1216
1270
|
writeState();
|
|
@@ -1226,23 +1280,23 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1226
1280
|
resetTurnToIdle();
|
|
1227
1281
|
}, FINAL_MESSAGE_HANDOFF_MS);
|
|
1228
1282
|
}
|
|
1229
|
-
function markPendingFinalDelivery(finalText, turn, suppressAutoReply = false) {
|
|
1283
|
+
function markPendingFinalDelivery(finalText, turn, suppressAutoReply = false, progress = {}) {
|
|
1230
1284
|
clearIdleResetTimer();
|
|
1231
|
-
const retryCount = session.pendingFinalDelivery?.turnKey === turn.turnKey
|
|
1232
|
-
? session.pendingFinalDelivery.retryCount
|
|
1233
|
-
: 0;
|
|
1234
1285
|
session.pendingFinalText = finalText;
|
|
1235
|
-
|
|
1286
|
+
// Records how far the send got, so the retry resumes at the first part that
|
|
1287
|
+
// is NOT durable instead of replaying the answer from part 1.
|
|
1288
|
+
session.pendingFinalDelivery = buildClaudePendingFinalDelivery({
|
|
1289
|
+
previous: session.pendingFinalDelivery,
|
|
1236
1290
|
turnKey: turn.turnKey,
|
|
1237
1291
|
text: finalText,
|
|
1238
|
-
retryCount,
|
|
1239
|
-
...(suppressAutoReply ? { suppressAutoReply: true } : {}),
|
|
1240
1292
|
messageId: buildClaudeFinalMessageId({
|
|
1241
1293
|
agentId,
|
|
1242
1294
|
conversationId,
|
|
1243
1295
|
turnKey: turn.turnKey,
|
|
1244
1296
|
}),
|
|
1245
|
-
|
|
1297
|
+
suppressAutoReply,
|
|
1298
|
+
...progress,
|
|
1299
|
+
});
|
|
1246
1300
|
session.state.state = 'running';
|
|
1247
1301
|
session.turnState = 'streaming';
|
|
1248
1302
|
stopVisibleWorkSignal();
|
|
@@ -1251,6 +1305,119 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1251
1305
|
writeTurn();
|
|
1252
1306
|
typingSignals.start(conversationId, 'typing').catch(() => { });
|
|
1253
1307
|
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Hand the turn slot back for a turn that has finished — unless a NEWER turn
|
|
1310
|
+
* already holds it.
|
|
1311
|
+
*
|
|
1312
|
+
* Finishing a turn spans awaits (the final send, the context-usage read), and
|
|
1313
|
+
* the session reads 'idle' to inbound messages from the SDK's idle echo
|
|
1314
|
+
* onward. A message arriving in that window reserves the slot for itself;
|
|
1315
|
+
* releasing it unconditionally afterwards would hand the same slot to a
|
|
1316
|
+
* second message and race two turns into the SDK — the very thing the
|
|
1317
|
+
* reservation exists to prevent.
|
|
1318
|
+
*/
|
|
1319
|
+
function releaseTurnSlotFor(turn) {
|
|
1320
|
+
if (!shouldReleaseClaudeTurnSlot({
|
|
1321
|
+
dispatching: session.dispatchingInput !== null,
|
|
1322
|
+
activeTurnKey: session.activeInput?.turnKey ?? null,
|
|
1323
|
+
completedTurnKey: turn?.turnKey ?? null,
|
|
1324
|
+
}))
|
|
1325
|
+
return;
|
|
1326
|
+
releaseClaudeTurnSlot(session);
|
|
1327
|
+
}
|
|
1328
|
+
function markFinalTurnDelivered(turn) {
|
|
1329
|
+
session.finalizedTurnKeys.add(turn.turnKey);
|
|
1330
|
+
markInputCompleted(turn);
|
|
1331
|
+
releaseTurnSlotFor(turn);
|
|
1332
|
+
session.pendingFinalDelivery = null;
|
|
1333
|
+
clearFinalDeliveryTimer();
|
|
1334
|
+
writeState();
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* The standalone "Canon rejected the reply" message — additive, and it
|
|
1338
|
+
* carries the `turn_complete` the interim→final handoff needs. Shared by the
|
|
1339
|
+
* permanent-rejection path and by retry exhaustion after a partial chunked
|
|
1340
|
+
* delivery, so both say the same thing under the same id.
|
|
1341
|
+
*/
|
|
1342
|
+
async function sendUndeliverableFinalNotice(input) {
|
|
1343
|
+
const selfContextId = input.selfContextId !== undefined
|
|
1344
|
+
? input.selfContextId
|
|
1345
|
+
: session.activeSelfContextId;
|
|
1346
|
+
try {
|
|
1347
|
+
await sendMessageWithRetry(client, conversationId, input.notice, {
|
|
1348
|
+
messageId: `${input.finalMessageId}-undelivered`,
|
|
1349
|
+
...(selfContextId ? { selfContextId } : {}),
|
|
1350
|
+
metadata: {
|
|
1351
|
+
turnId: session.currentTurnId,
|
|
1352
|
+
turnSemantics: 'turn_complete',
|
|
1353
|
+
replyBehavior: 'suppress_auto_reply',
|
|
1354
|
+
},
|
|
1355
|
+
});
|
|
1356
|
+
return 'notice';
|
|
1357
|
+
}
|
|
1358
|
+
catch (noticeErr) {
|
|
1359
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Undeliverable-final notice also rejected:`, noticeErr);
|
|
1360
|
+
return 'none';
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Last resort when Canon permanently refuses the final: get SOMETHING durable
|
|
1365
|
+
* into the conversation rather than letting the turn end on a torn-down live
|
|
1366
|
+
* bubble.
|
|
1367
|
+
*
|
|
1368
|
+
* A SINGLE-MESSAGE final is re-sent under the normal final message id with no
|
|
1369
|
+
* turn trail — the trail is the likeliest residual cause of a 4xx, and that id
|
|
1370
|
+
* is idempotent, threads correctly and completes the interim handoff.
|
|
1371
|
+
* (`buildTruncatedFinalText` is the bound that keeps the payload legal; text
|
|
1372
|
+
* reaching this path already fits, so nothing is actually cut.)
|
|
1373
|
+
*
|
|
1374
|
+
* A CHUNKED final is deliberately NOT re-sent. Its parts are already durable
|
|
1375
|
+
* under `${finalMessageId}-part-N`, so re-sending the answer's head would
|
|
1376
|
+
* repeat what the reader already has and still leave the tail missing — and
|
|
1377
|
+
* the resume path has by now exhausted the ways of delivering that tail. Only
|
|
1378
|
+
* the notice goes out — additive, and it carries the `turn_complete` the
|
|
1379
|
+
* handoff needs.
|
|
1380
|
+
*/
|
|
1381
|
+
async function surfaceUndeliverableFinal(input) {
|
|
1382
|
+
const selfContextId = input.selfContextId !== undefined
|
|
1383
|
+
? input.selfContextId
|
|
1384
|
+
: session.activeSelfContextId;
|
|
1385
|
+
const selfContext = selfContextId ? { selfContextId } : {};
|
|
1386
|
+
if (!input.chunked) {
|
|
1387
|
+
const degraded = buildTruncatedFinalText(input.finalText);
|
|
1388
|
+
try {
|
|
1389
|
+
await sendMessageWithRetry(client, conversationId, degraded, {
|
|
1390
|
+
messageId: input.finalMessageId,
|
|
1391
|
+
...selfContext,
|
|
1392
|
+
// No turn trail: the metadata is a candidate cause of a residual 4xx,
|
|
1393
|
+
// and an activity record is worth less than the answer itself.
|
|
1394
|
+
metadata: buildClaudeFinalTurnMetadata({
|
|
1395
|
+
turnId: session.currentTurnId,
|
|
1396
|
+
turnKey: input.turn.turnKey,
|
|
1397
|
+
sourceMessageId: input.turn.sourceMessageId,
|
|
1398
|
+
deliveryIntent: session.lastAcceptedIntent,
|
|
1399
|
+
suppressAutoReply: input.suppressAutoReply,
|
|
1400
|
+
}),
|
|
1401
|
+
});
|
|
1402
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
|
|
1403
|
+
+ (degraded.length === input.finalText.length
|
|
1404
|
+
? 'Delivered the final without its turn trail after a permanent rejection'
|
|
1405
|
+
: `Delivered a truncated final (${degraded.length} of ${input.finalText.length} chars) after a permanent rejection`));
|
|
1406
|
+
return 'resent';
|
|
1407
|
+
}
|
|
1408
|
+
catch (resendErr) {
|
|
1409
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Trail-free final also rejected:`, resendErr);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
return sendUndeliverableFinalNotice({
|
|
1413
|
+
finalMessageId: input.finalMessageId,
|
|
1414
|
+
selfContextId,
|
|
1415
|
+
notice: buildUndeliverableFinalNotice({
|
|
1416
|
+
error: input.error,
|
|
1417
|
+
partiallyDelivered: input.partiallyDelivered,
|
|
1418
|
+
}),
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1254
1421
|
async function deliverFinalReply(finalText, turn = session.activeInput, suppressAutoReply = false) {
|
|
1255
1422
|
if (!shouldDeliverClaudeFinal({
|
|
1256
1423
|
turn,
|
|
@@ -1267,33 +1434,100 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1267
1434
|
conversationId,
|
|
1268
1435
|
turnKey: deliverTurn.turnKey,
|
|
1269
1436
|
});
|
|
1437
|
+
// A final over Canon's 4 KB text cap is split into ordered messages instead
|
|
1438
|
+
// of being rejected whole. A final that fits keeps the plain message id and
|
|
1439
|
+
// metadata core uses for single-chunk sends, so the common case is
|
|
1440
|
+
// unchanged.
|
|
1441
|
+
const willChunk = claudeFinalWillChunk(finalText);
|
|
1442
|
+
// A retry of THIS turn's final picks up where the last attempt stopped: the
|
|
1443
|
+
// parts already durable are skipped, and the metadata that attempt built is
|
|
1444
|
+
// replayed byte-for-byte (the server's idempotency fingerprint spans
|
|
1445
|
+
// metadata, so a rebuilt trail would turn a replay into a 409).
|
|
1446
|
+
const resume = readClaudeFinalDeliveryResume(session.pendingFinalDelivery, deliverTurn.turnKey);
|
|
1447
|
+
const metadata = resume.metadata ?? boundClaudeFinalMetadata(buildClaudeFinalTurnMetadata({
|
|
1448
|
+
turnId: session.currentTurnId,
|
|
1449
|
+
turnKey: deliverTurn.turnKey,
|
|
1450
|
+
sourceMessageId: deliverTurn.sourceMessageId,
|
|
1451
|
+
deliveryIntent: session.lastAcceptedIntent,
|
|
1452
|
+
turnTrail: getFinalTurnTrail(willChunk),
|
|
1453
|
+
suppressAutoReply,
|
|
1454
|
+
}));
|
|
1455
|
+
// Pinned for the same reason as the metadata: `selfContextId` is part of the
|
|
1456
|
+
// server's idempotency fingerprint, and `session.activeSelfContextId` moves
|
|
1457
|
+
// with every inbound message — including the ones that merely queue behind
|
|
1458
|
+
// this delivery. Reading it fresh per attempt would land a resumed tail in a
|
|
1459
|
+
// different self-context from its own head.
|
|
1460
|
+
const selfContextId = resume.selfContextId !== undefined
|
|
1461
|
+
? resume.selfContextId
|
|
1462
|
+
: session.activeSelfContextId;
|
|
1270
1463
|
try {
|
|
1271
|
-
await
|
|
1464
|
+
const { messageIds } = await sendMessageWithRetryChunked(client, conversationId, finalText, {
|
|
1272
1465
|
messageId: finalMessageId,
|
|
1273
|
-
...(
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
suppressAutoReply,
|
|
1283
|
-
}),
|
|
1284
|
-
});
|
|
1285
|
-
session.finalizedTurnKeys.add(deliverTurn.turnKey);
|
|
1286
|
-
markInputCompleted(deliverTurn);
|
|
1287
|
-
session.state.state = 'idle';
|
|
1288
|
-
session.pendingFinalDelivery = null;
|
|
1289
|
-
clearFinalDeliveryTimer();
|
|
1290
|
-
writeState();
|
|
1291
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars)`);
|
|
1466
|
+
...(selfContextId ? { selfContextId } : {}),
|
|
1467
|
+
metadata,
|
|
1468
|
+
}, {}, buildClaudeFinalChunkingOptions(resume));
|
|
1469
|
+
markFinalTurnDelivered(deliverTurn);
|
|
1470
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars`
|
|
1471
|
+
+ `${messageIds.length > 1 ? `, ${messageIds.length} parts` : ''}`
|
|
1472
|
+
+ `${resume.deliveredMessageIds.length > 0
|
|
1473
|
+
? `, resumed at part ${resume.deliveredMessageIds.length + 1}`
|
|
1474
|
+
: ''})`);
|
|
1292
1475
|
return true;
|
|
1293
1476
|
}
|
|
1294
1477
|
catch (err) {
|
|
1295
|
-
|
|
1296
|
-
|
|
1478
|
+
// A chunked failure carries how far it got; everything below classifies
|
|
1479
|
+
// and reports the CAUSE, not the wrapper.
|
|
1480
|
+
const chunkedFailure = isChunkedSendMessageError(err) ? err : null;
|
|
1481
|
+
const cause = chunkedFailure ? chunkedFailure.cause : err;
|
|
1482
|
+
const deliveredMessageIds = chunkedFailure
|
|
1483
|
+
? chunkedFailure.deliveredMessageIds
|
|
1484
|
+
: resume.deliveredMessageIds;
|
|
1485
|
+
const failure = classifyFinalDeliveryFailure(cause, { chunked: willChunk });
|
|
1486
|
+
if (failure === 'already-delivered') {
|
|
1487
|
+
// The server's idempotency guard says this final is already durable.
|
|
1488
|
+
markFinalTurnDelivered(deliverTurn);
|
|
1489
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply was already delivered under this id`);
|
|
1490
|
+
return true;
|
|
1491
|
+
}
|
|
1492
|
+
if (failure === 'permanent') {
|
|
1493
|
+
// Re-sending is pointless — the same request fails the same way. Surface
|
|
1494
|
+
// what can be surfaced and finish the turn instead of burning the retry
|
|
1495
|
+
// budget and dropping the answer in silence.
|
|
1496
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply permanently rejected:`, err);
|
|
1497
|
+
// Surface FIRST, mark delivered SECOND. markFinalTurnDelivered persists
|
|
1498
|
+
// the inbound recovery cursor (so a restart will not replay this
|
|
1499
|
+
// message) and reopens the session to new input; neither may happen
|
|
1500
|
+
// while delivery is still unresolved.
|
|
1501
|
+
const outcome = await surfaceUndeliverableFinal({
|
|
1502
|
+
finalText,
|
|
1503
|
+
turn: deliverTurn,
|
|
1504
|
+
finalMessageId,
|
|
1505
|
+
suppressAutoReply,
|
|
1506
|
+
selfContextId,
|
|
1507
|
+
chunked: willChunk,
|
|
1508
|
+
// The notice only claims the reply "stops short" when parts of it
|
|
1509
|
+
// really are in the conversation — now known exactly, instead of
|
|
1510
|
+
// being inferred from "this was a chunked send".
|
|
1511
|
+
partiallyDelivered: deliveredMessageIds.length > 0,
|
|
1512
|
+
error: cause,
|
|
1513
|
+
});
|
|
1514
|
+
if (outcome === 'none') {
|
|
1515
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
|
|
1516
|
+
+ 'Nothing durable could be sent for this turn; the streamed text is the only record left');
|
|
1517
|
+
}
|
|
1518
|
+
markFinalTurnDelivered(deliverTurn);
|
|
1519
|
+
return true;
|
|
1520
|
+
}
|
|
1521
|
+
markPendingFinalDelivery(finalText, deliverTurn, suppressAutoReply, {
|
|
1522
|
+
metadata,
|
|
1523
|
+
selfContextId,
|
|
1524
|
+
deliveredMessageIds,
|
|
1525
|
+
lastErrorMessage: cause instanceof Error ? cause.message : String(cause),
|
|
1526
|
+
});
|
|
1527
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply`
|
|
1528
|
+
+ `${deliveredMessageIds.length > 0
|
|
1529
|
+
? ` after ${deliveredMessageIds.length} of ${chunkedFailure?.totalParts ?? '?'} parts`
|
|
1530
|
+
: ''}:`, err);
|
|
1297
1531
|
return false;
|
|
1298
1532
|
}
|
|
1299
1533
|
}
|
|
@@ -1339,9 +1573,29 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1339
1573
|
if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
|
|
1340
1574
|
return;
|
|
1341
1575
|
if (pending.retryCount >= MAX_FINAL_DELIVERY_RETRIES) {
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1576
|
+
const deliveredParts = pending.deliveredMessageIds?.length ?? 0;
|
|
1577
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply delivery exhausted after bounded retries`
|
|
1578
|
+
+ `${deliveredParts > 0 ? ` with ${deliveredParts} part(s) delivered` : ''}`);
|
|
1579
|
+
// Part of the answer IS in the conversation and the tail is never coming;
|
|
1580
|
+
// the notice says it stops short instead of leaving the last delivered
|
|
1581
|
+
// part looking like the end. The pending record stays set until the turn
|
|
1582
|
+
// is actually retired, so inbound messages keep queueing across the send.
|
|
1583
|
+
void runClaudeExhaustedFinalDelivery(session, pending, {
|
|
1584
|
+
sendNotice: (notice) => sendUndeliverableFinalNotice({
|
|
1585
|
+
finalMessageId: pending.messageId,
|
|
1586
|
+
selfContextId: pending.selfContextId,
|
|
1587
|
+
notice,
|
|
1588
|
+
}),
|
|
1589
|
+
retireTurn: () => {
|
|
1590
|
+
// Hand the answer to the server-side safety net: clearing /streaming
|
|
1591
|
+
// fires onStreamingCleared, which persists the streamed text as a
|
|
1592
|
+
// durable progress message when the turn has no turn_complete.
|
|
1593
|
+
// Without this the live bubble was torn down by the idle reset and
|
|
1594
|
+
// the work was lost.
|
|
1595
|
+
clearStreaming().catch(() => { });
|
|
1596
|
+
resetTurnToIdle();
|
|
1597
|
+
},
|
|
1598
|
+
});
|
|
1345
1599
|
return;
|
|
1346
1600
|
}
|
|
1347
1601
|
pending.retryCount += 1;
|
|
@@ -1431,10 +1685,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1431
1685
|
writeState();
|
|
1432
1686
|
}
|
|
1433
1687
|
function writeTurn() {
|
|
1434
|
-
const isOpenTurn = session.turnState
|
|
1435
|
-
|| session.turnState === 'streaming'
|
|
1436
|
-
|| session.turnState === 'tool'
|
|
1437
|
-
|| session.turnState === 'waiting_input';
|
|
1688
|
+
const isOpenTurn = isOpenClaudeTurnState(session.turnState);
|
|
1438
1689
|
if (isOpenTurn) {
|
|
1439
1690
|
session.currentTurnUpdatedAt = Date.now();
|
|
1440
1691
|
}
|
|
@@ -1477,10 +1728,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1477
1728
|
async function prepareArtifactsForInput(input) {
|
|
1478
1729
|
if (input.kind !== 'canon')
|
|
1479
1730
|
return;
|
|
1480
|
-
|
|
1481
|
-
session.currentTurnId = randomUUID();
|
|
1482
|
-
session.currentTurnOpenedAt = Date.now();
|
|
1483
|
-
}
|
|
1731
|
+
// The turn id is minted by openTurn(), which runs before this.
|
|
1484
1732
|
if (input.artifactRoutingMode === 'workspace-generated') {
|
|
1485
1733
|
input.artifactBaseline = await captureTurnArtifactSnapshot({ cwd: session.cwd }).catch((error) => {
|
|
1486
1734
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact snapshot failed:`, error instanceof Error ? error.message : error);
|
|
@@ -1489,24 +1737,149 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1489
1737
|
return;
|
|
1490
1738
|
}
|
|
1491
1739
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1740
|
+
/**
|
|
1741
|
+
* Make the turn visible the moment it is dequeued, before the SDK has said
|
|
1742
|
+
* anything.
|
|
1743
|
+
*
|
|
1744
|
+
* Until now the first publish of any kind came from the SDK echoing
|
|
1745
|
+
* `session_state_changed: running`, and the first thing the reader saw came
|
|
1746
|
+
* later still, with the first streamed token — so an accepted message looked
|
|
1747
|
+
* like nothing had happened. Opening synchronously here (Codex does the same
|
|
1748
|
+
* at its dequeue) gives the reader the thinking header, the typing dots and a
|
|
1749
|
+
* live turn row immediately; the seed is status-only because text written to
|
|
1750
|
+
* `/streaming` is rendered as the agent's speech.
|
|
1751
|
+
*/
|
|
1752
|
+
function openTurn(input) {
|
|
1753
|
+
// Only Canon turns are user-facing; internal envelopes stay invisible.
|
|
1754
|
+
if (session.closed || !claudeInputOwnsTurnSlot(input))
|
|
1755
|
+
return;
|
|
1756
|
+
clearIdleResetTimer();
|
|
1757
|
+
openClaudeTurn(session);
|
|
1758
|
+
startVisibleWorkSignal();
|
|
1759
|
+
writeTurn();
|
|
1760
|
+
seedTurnStreaming();
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* Open `/streaming` for a turn that is starting: drop whatever the previous
|
|
1764
|
+
* turn left there, then seed the empty thinking node.
|
|
1765
|
+
*
|
|
1766
|
+
* The clear is what makes the seed empty. `startThinking('')` resets the
|
|
1767
|
+
* node's TEXT and nothing else — the controller's block array survives until
|
|
1768
|
+
* `clear()` — and `openTurn` cancels (`clearIdleResetTimer`) the handoff reset
|
|
1769
|
+
* that would otherwise have cleared it. So a turn opened inside the 750ms
|
|
1770
|
+
* handoff window would republish the FINISHED turn's receipts and plan card
|
|
1771
|
+
* under its own id, `openClaudeTurn` having just dropped the bookkeeping that
|
|
1772
|
+
* could still complete them, and `getFinalTurnTrail` would then hang them on
|
|
1773
|
+
* the next final's persisted trail.
|
|
1774
|
+
*
|
|
1775
|
+
* Safe with respect to functions' `onStreamingCleared`: the node being
|
|
1776
|
+
* deleted still carries the finished turn's id, which by definition has a
|
|
1777
|
+
* `turn_complete` message (its final was delivered), so the fallback skips as
|
|
1778
|
+
* `turn_complete_exists`. On every other path the node is already gone and
|
|
1779
|
+
* the delete has nothing to fire on.
|
|
1780
|
+
*/
|
|
1781
|
+
function seedTurnStreaming() {
|
|
1782
|
+
clearStreaming().catch(() => { });
|
|
1783
|
+
streamingOutput.startThinking('').catch(() => { });
|
|
1784
|
+
}
|
|
1785
|
+
function applyTrailCommands(commands) {
|
|
1786
|
+
for (const command of commands) {
|
|
1787
|
+
switch (command.op) {
|
|
1788
|
+
case 'add':
|
|
1789
|
+
streamingOutput.addBlock({
|
|
1790
|
+
id: command.id,
|
|
1791
|
+
kind: command.kind,
|
|
1792
|
+
...(command.status ? { status: command.status } : {}),
|
|
1793
|
+
...(command.title ? { title: command.title } : {}),
|
|
1794
|
+
...(command.text ? { text: command.text } : {}),
|
|
1795
|
+
}).catch(() => { });
|
|
1796
|
+
break;
|
|
1797
|
+
case 'update':
|
|
1798
|
+
streamingOutput.updateBlock(command.id, {
|
|
1799
|
+
...(command.title !== undefined ? { title: command.title } : {}),
|
|
1800
|
+
...(command.summary !== undefined ? { summary: command.summary } : {}),
|
|
1801
|
+
}).catch(() => { });
|
|
1802
|
+
break;
|
|
1803
|
+
case 'complete':
|
|
1804
|
+
streamingOutput.completeBlock(command.id, command.summary === undefined ? undefined : { summary: command.summary }).catch(() => { });
|
|
1805
|
+
break;
|
|
1806
|
+
case 'fail':
|
|
1807
|
+
streamingOutput.failBlock(command.id, { summary: command.summary }).catch(() => { });
|
|
1808
|
+
break;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Settle an input that never reached Claude.
|
|
1814
|
+
*
|
|
1815
|
+
* Both ways out of the dispatch window used to just drop it: the sender's
|
|
1816
|
+
* message stayed 'accepted' forever, the id stayed in the host's in-flight
|
|
1817
|
+
* set (it is in neither `pendingInputs` nor `activeInput`, so no reset path
|
|
1818
|
+
* settles it either), and the reader watched a 'thinking' turn appear and
|
|
1819
|
+
* vanish with no answer. Telling the sender it was rejected is the honest
|
|
1820
|
+
* ending, and it matches what every other undelivered input gets.
|
|
1821
|
+
*/
|
|
1822
|
+
function abandonDispatchedInput(input, reason, error) {
|
|
1823
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
|
|
1824
|
+
+ `Turn dispatch ${reason === 'closed' ? 'abandoned: session closed mid-dispatch' : 'failed'} `
|
|
1825
|
+
+ `before the input reached Claude${input.sourceMessageId ? ` (message ${input.sourceMessageId})` : ''}${error === undefined ? '' : ':'}`, ...(error === undefined ? [] : [error instanceof Error ? error.message : error]));
|
|
1826
|
+
if (input.markAccepted && input.sourceMessageId) {
|
|
1827
|
+
void client
|
|
1828
|
+
.updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected')
|
|
1829
|
+
.catch(() => { });
|
|
1830
|
+
}
|
|
1831
|
+
markInputCompleted(input);
|
|
1832
|
+
// resetTurnToIdle releases the slot, tears the published turn down (typing
|
|
1833
|
+
// signal, seeded /streaming, turn state) and drains the queue. On a closed
|
|
1834
|
+
// session it is a no-op, which is correct: the teardown owns that path.
|
|
1835
|
+
resetTurnToIdle();
|
|
1836
|
+
}
|
|
1837
|
+
function startCanonInput(input) {
|
|
1838
|
+
return dispatchClaudeInput(session, input, {
|
|
1839
|
+
openTurn,
|
|
1840
|
+
markAccepted: (dispatched) => markQueuedMessageAccepted(dispatched.sourceMessageId, dispatched.markAccepted),
|
|
1841
|
+
prepareArtifacts: prepareArtifactsForInput,
|
|
1842
|
+
send: sendInput,
|
|
1843
|
+
abandon: abandonDispatchedInput,
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* Fire-and-forget dispatch. `startCanonInput` handles its own failures, so
|
|
1848
|
+
* this only catches what could throw before or after them — without it a
|
|
1849
|
+
* rejection here would surface as an unhandled rejection and take the host
|
|
1850
|
+
* process with it under `--unhandled-rejections=strict`.
|
|
1851
|
+
*/
|
|
1852
|
+
function dispatchCanonInput(input) {
|
|
1853
|
+
startCanonInput(input).catch((error) => {
|
|
1854
|
+
if (session.dispatchingInput === input)
|
|
1855
|
+
session.dispatchingInput = null;
|
|
1856
|
+
abandonDispatchedInput(input, 'error', error);
|
|
1857
|
+
});
|
|
1497
1858
|
}
|
|
1498
1859
|
function drainPendingInput() {
|
|
1499
|
-
if (
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1860
|
+
if (!canDrainClaudeQueuedInput({
|
|
1861
|
+
closed: session.closed,
|
|
1862
|
+
controlInterruptPending: session.controlInterruptPending,
|
|
1863
|
+
sessionState: session.state.state,
|
|
1864
|
+
queueDepth: session.pendingInputs.length,
|
|
1865
|
+
}))
|
|
1503
1866
|
return;
|
|
1504
1867
|
const next = session.pendingInputs.shift();
|
|
1505
1868
|
if (!next)
|
|
1506
1869
|
return;
|
|
1507
1870
|
session.lastAcceptedIntent = next.intent;
|
|
1508
|
-
writeTurn()
|
|
1509
|
-
|
|
1871
|
+
// No writeTurn() here: it would publish the still-'idle' state (with a
|
|
1872
|
+
// completedAt) for a turn that is starting. openTurn() publishes 'thinking'
|
|
1873
|
+
// synchronously inside startCanonInput, carrying the new queue depth.
|
|
1874
|
+
dispatchCanonInput(next);
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* True when an input has actually reached the SDK. Everything an interrupt
|
|
1878
|
+
* acts on is keyed to this: in the dispatch window the input is still in the
|
|
1879
|
+
* host's hands, so there is nothing for `query.interrupt()` to stop.
|
|
1880
|
+
*/
|
|
1881
|
+
function hasInterruptibleSdkInput() {
|
|
1882
|
+
return session.activeInput !== null && session.dispatchingInput === null;
|
|
1510
1883
|
}
|
|
1511
1884
|
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => {
|
|
1512
1885
|
session.lastActivity = Date.now();
|
|
@@ -1520,17 +1893,28 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1520
1893
|
requestingUserId,
|
|
1521
1894
|
artifactRoutingMode,
|
|
1522
1895
|
});
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1896
|
+
const decision = decideClaudeInboundDispatch({
|
|
1897
|
+
intent,
|
|
1898
|
+
sessionState: session.state.state,
|
|
1899
|
+
hasPendingFinalDelivery: Boolean(session.pendingFinalDelivery),
|
|
1900
|
+
hasInterruptibleInput: hasInterruptibleSdkInput(),
|
|
1901
|
+
});
|
|
1902
|
+
if (decision === 'queue' || decision === 'queue-front') {
|
|
1903
|
+
if (decision === 'queue-front') {
|
|
1904
|
+
session.pendingInputs.unshift(input);
|
|
1905
|
+
// An interrupt that arrived while the previous message was still being
|
|
1906
|
+
// dispatched: nothing is in the SDK to stop, so it takes the front of
|
|
1907
|
+
// the queue and starts the moment that turn ends.
|
|
1908
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
|
|
1909
|
+
+ 'Interrupt arrived during turn dispatch; queued at the front instead');
|
|
1910
|
+
}
|
|
1911
|
+
else {
|
|
1912
|
+
session.pendingInputs.push(input);
|
|
1913
|
+
}
|
|
1530
1914
|
writeTurn();
|
|
1531
1915
|
return;
|
|
1532
1916
|
}
|
|
1533
|
-
if (
|
|
1917
|
+
if (decision === 'interrupt') {
|
|
1534
1918
|
session.pendingInputs.unshift(input);
|
|
1535
1919
|
writeTurn();
|
|
1536
1920
|
const interruptedInput = session.activeInput;
|
|
@@ -1549,10 +1933,27 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1549
1933
|
return;
|
|
1550
1934
|
}
|
|
1551
1935
|
session.lastAcceptedIntent = intent;
|
|
1552
|
-
|
|
1553
|
-
|
|
1936
|
+
// Same as in drainPendingInput: openTurn() does the publish, as 'thinking'.
|
|
1937
|
+
dispatchCanonInput(input);
|
|
1554
1938
|
};
|
|
1555
1939
|
writeTurn();
|
|
1940
|
+
// ── Turn activity (margin trail) ──
|
|
1941
|
+
/** True when this `content_block_stop` closes a tool call this turn claimed. */
|
|
1942
|
+
function isToolBlockStop(event) {
|
|
1943
|
+
if (takeClaudeToolBlockIdByIndex(session.turnActivity, event?.index))
|
|
1944
|
+
return true;
|
|
1945
|
+
// Older runtimes may omit the index; fall back to the coarse flag rather
|
|
1946
|
+
// than leaving the turn stuck in 'tool'.
|
|
1947
|
+
return typeof event?.index !== 'number' && session.toolInProgress;
|
|
1948
|
+
}
|
|
1949
|
+
// The plan card is deliberately NOT pushed into the live bubble the way Codex
|
|
1950
|
+
// does: Claude streams real speech there, and the plan belongs in the margin.
|
|
1951
|
+
function applyAssistantToolCalls(content) {
|
|
1952
|
+
applyTrailCommands(planClaudeAssistantTrail(session.turnActivity, content, session.currentTurnId ?? conversationId));
|
|
1953
|
+
}
|
|
1954
|
+
function applyToolResults(content) {
|
|
1955
|
+
applyTrailCommands(planClaudeToolResults(session.turnActivity, content));
|
|
1956
|
+
}
|
|
1556
1957
|
// ── Process SDK output messages ──
|
|
1557
1958
|
const processMessages = async () => {
|
|
1558
1959
|
try {
|
|
@@ -1565,9 +1966,27 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1565
1966
|
const assistantSessionId = msg.session_id;
|
|
1566
1967
|
if (assistantSessionId)
|
|
1567
1968
|
session.sdkSessionId = assistantSessionId;
|
|
1568
|
-
|
|
1569
|
-
|
|
1969
|
+
// Only a Canon turn's text is user-facing. This used to skip the
|
|
1970
|
+
// FIRST assistant message on the assumption it answered the seed,
|
|
1971
|
+
// but the seed is pulled after the first real message, so the guard
|
|
1972
|
+
// swallowed the actual reply — no streamed text and no fallback for
|
|
1973
|
+
// the final. Gate on the owning turn instead of on arrival order.
|
|
1974
|
+
if (!claudeInputOwnsTurnSlot(session.activeInput))
|
|
1570
1975
|
break;
|
|
1976
|
+
// A subagent's message is not this agent's speech. Its narration is
|
|
1977
|
+
// already kept out of the live bubble and its calls out of the
|
|
1978
|
+
// trail, so it must not become `pendingFinalText` either: that is
|
|
1979
|
+
// the fallback the final falls back to when the result carries no
|
|
1980
|
+
// text, and delivering it would surface as the answer text nobody
|
|
1981
|
+
// ever saw streamed.
|
|
1982
|
+
if (!isClaudeMainTurnMessage(msg.parent_tool_use_id))
|
|
1983
|
+
break;
|
|
1984
|
+
// The assistant message is the first place the tool call's typed
|
|
1985
|
+
// INPUT is visible — content_block_start carries only the name. It
|
|
1986
|
+
// is what turns "Bash" into "Running: npm test" and TodoWrite into
|
|
1987
|
+
// the plan card.
|
|
1988
|
+
if (!isActiveTurnInterrupted()) {
|
|
1989
|
+
applyAssistantToolCalls(msg.message?.content ?? []);
|
|
1571
1990
|
}
|
|
1572
1991
|
const textBlocks = (msg.message?.content ?? [])
|
|
1573
1992
|
.filter((b) => b.type === 'text')
|
|
@@ -1584,23 +2003,36 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1584
2003
|
stopVisibleWorkSignal();
|
|
1585
2004
|
break;
|
|
1586
2005
|
}
|
|
2006
|
+
// A subagent's own stream stays out of the turn ENTIRELY — its tool
|
|
2007
|
+
// calls and its text alike. The Task row stands for the whole
|
|
2008
|
+
// subagent; its calls would flood the trail (and the 20-block
|
|
2009
|
+
// budget the final's trail is bounded to), and its narration is not
|
|
2010
|
+
// this agent's speech: streamed into the live bubble it collides
|
|
2011
|
+
// with the main agent's own text segments and corrupts the answer
|
|
2012
|
+
// being written.
|
|
2013
|
+
if (!isClaudeMainTurnMessage(msg.parent_tool_use_id))
|
|
2014
|
+
break;
|
|
1587
2015
|
if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
|
2016
|
+
// Claimed per CALL, keyed by the SDK's tool_use id. The old id was
|
|
2017
|
+
// the turn id, so every call in a turn wrote over the same row.
|
|
2018
|
+
const commands = planClaudeToolCallStart(session.turnActivity, {
|
|
2019
|
+
toolUseId: event.content_block?.id,
|
|
2020
|
+
index: event.index,
|
|
2021
|
+
name: event.content_block?.name,
|
|
2022
|
+
turnId: session.currentTurnId ?? conversationId,
|
|
2023
|
+
});
|
|
1588
2024
|
session.toolInProgress = true;
|
|
1589
2025
|
session.turnState = 'tool';
|
|
1590
|
-
|
|
1591
|
-
id: `tool:${session.currentTurnId ?? conversationId}`,
|
|
1592
|
-
kind: 'tool',
|
|
1593
|
-
status: 'running',
|
|
1594
|
-
title: typeof event.content_block?.name === 'string'
|
|
1595
|
-
? event.content_block.name
|
|
1596
|
-
: 'Claude tool use',
|
|
1597
|
-
}).catch(() => { });
|
|
2026
|
+
applyTrailCommands(commands);
|
|
1598
2027
|
startVisibleWorkSignal();
|
|
1599
2028
|
writeTurn();
|
|
1600
2029
|
}
|
|
1601
|
-
else if (event?.type === 'content_block_stop' &&
|
|
2030
|
+
else if (event?.type === 'content_block_stop' && isToolBlockStop(event)) {
|
|
2031
|
+
// The MODEL has finished writing the call; the tool has not run
|
|
2032
|
+
// yet. The row therefore stays 'running' until its tool_result
|
|
2033
|
+
// arrives — "completed" in the margin has to mean the tool
|
|
2034
|
+
// returned, which is what the old code got wrong.
|
|
1602
2035
|
session.toolInProgress = false;
|
|
1603
|
-
streamingOutput.completeBlock(`tool:${session.currentTurnId ?? conversationId}`).catch(() => { });
|
|
1604
2036
|
if (session.state.state === 'running') {
|
|
1605
2037
|
session.turnState = 'thinking';
|
|
1606
2038
|
startVisibleWorkSignal();
|
|
@@ -1617,21 +2049,70 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1617
2049
|
}
|
|
1618
2050
|
break;
|
|
1619
2051
|
}
|
|
2052
|
+
case 'user': {
|
|
2053
|
+
// Where a tool call's OUTCOME arrives. Without this the margin's
|
|
2054
|
+
// receipts never resolved: rows were "completed" the moment the
|
|
2055
|
+
// model finished writing the call, and a failing tool looked
|
|
2056
|
+
// identical to a successful one.
|
|
2057
|
+
if (!isClaudeMainTurnMessage(msg.parent_tool_use_id))
|
|
2058
|
+
break;
|
|
2059
|
+
if (!claudeInputOwnsTurnSlot(session.activeInput))
|
|
2060
|
+
break;
|
|
2061
|
+
if (isActiveTurnInterrupted())
|
|
2062
|
+
break;
|
|
2063
|
+
applyToolResults(msg.message?.content);
|
|
2064
|
+
break;
|
|
2065
|
+
}
|
|
2066
|
+
case 'tool_progress': {
|
|
2067
|
+
// A long call would otherwise sit as a bare running row while the
|
|
2068
|
+
// typing signal quietly aged out.
|
|
2069
|
+
if (!isClaudeMainTurnMessage(msg.parent_tool_use_id))
|
|
2070
|
+
break;
|
|
2071
|
+
if (isActiveTurnInterrupted())
|
|
2072
|
+
break;
|
|
2073
|
+
applyTrailCommands(planClaudeToolProgress(session.turnActivity, {
|
|
2074
|
+
toolUseId: msg.tool_use_id,
|
|
2075
|
+
elapsedSeconds: msg.elapsed_time_seconds,
|
|
2076
|
+
}));
|
|
2077
|
+
refreshVisibleWorkSignal();
|
|
2078
|
+
break;
|
|
2079
|
+
}
|
|
1620
2080
|
case 'system': {
|
|
1621
2081
|
const subtype = msg.subtype;
|
|
1622
2082
|
if (subtype === 'session_state_changed') {
|
|
1623
2083
|
const state = msg.state;
|
|
2084
|
+
// The echo shares one field with the host's own reservation and
|
|
2085
|
+
// lags behind it: the 'idle' closing turn A arrives after A's
|
|
2086
|
+
// result, and the queue is drained INSIDE that result handler, so
|
|
2087
|
+
// turn B can already hold the slot. Applying it there would free a
|
|
2088
|
+
// slot that is taken and stop the typing signal B just started.
|
|
2089
|
+
if (!shouldApplyClaudeEchoedSessionState({
|
|
2090
|
+
echoed: state,
|
|
2091
|
+
dispatching: session.dispatchingInput !== null,
|
|
2092
|
+
hasPendingFinalDelivery: Boolean(session.pendingFinalDelivery),
|
|
2093
|
+
})) {
|
|
2094
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
|
|
2095
|
+
+ 'Ignoring a stale idle echo: the host still holds the turn slot');
|
|
2096
|
+
break;
|
|
2097
|
+
}
|
|
1624
2098
|
session.state.state = state;
|
|
1625
2099
|
if (state === 'running') {
|
|
1626
2100
|
clearIdleResetTimer();
|
|
1627
|
-
if (!session.currentTurnId) {
|
|
1628
|
-
session.currentTurnId = randomUUID();
|
|
1629
|
-
session.currentTurnOpenedAt = Date.now();
|
|
1630
|
-
}
|
|
1631
2101
|
session.state.contextUsage = undefined;
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
2102
|
+
// Idempotent confirmation of a turn the host already opened —
|
|
2103
|
+
// re-seeding /streaming here would wipe the blocks and text the
|
|
2104
|
+
// turn has produced since. It still opens turns the host did not
|
|
2105
|
+
// start itself (an internal envelope, or a resumed session).
|
|
2106
|
+
if (shouldOpenClaudeTurnOnRunning(session.turnState)) {
|
|
2107
|
+
openClaudeTurn(session);
|
|
2108
|
+
seedTurnStreaming();
|
|
2109
|
+
}
|
|
2110
|
+
else if (session.turnState === 'waiting_input') {
|
|
2111
|
+
// An approval was answered: the same turn is working again,
|
|
2112
|
+
// so it leaves 'waiting_input' without being reopened.
|
|
2113
|
+
session.turnState = 'thinking';
|
|
2114
|
+
session.toolInProgress = false;
|
|
2115
|
+
}
|
|
1635
2116
|
startVisibleWorkSignal();
|
|
1636
2117
|
}
|
|
1637
2118
|
else if (state === 'idle') {
|
|
@@ -1657,7 +2138,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1657
2138
|
const resultSessionId = msg.session_id;
|
|
1658
2139
|
if (resultSessionId)
|
|
1659
2140
|
session.sdkSessionId = resultSessionId;
|
|
1660
|
-
|
|
2141
|
+
// Match the result to the envelope that produced it. Falling back to
|
|
2142
|
+
// the slot only when the SDK reports no uuid keeps older runtimes
|
|
2143
|
+
// working, at their existing accuracy.
|
|
2144
|
+
const completedInput = takeClaudeResultOwner(dispatchedInputs, msg.user_message_uuid) ?? session.activeInput;
|
|
1661
2145
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
|
|
1662
2146
|
// Turn artifacts land before the final text reply.
|
|
1663
2147
|
if (completedInput?.kind === 'canon'
|
|
@@ -1690,6 +2174,19 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1690
2174
|
interruptedTurnKeys: session.interruptedTurnKeys,
|
|
1691
2175
|
})
|
|
1692
2176
|
: false;
|
|
2177
|
+
// Gated finals are dropped without sending. That is correct for the
|
|
2178
|
+
// seed's own turn, but it is also how a misattributed Canon reply
|
|
2179
|
+
// disappears — silently, which is why this class of bug went unseen.
|
|
2180
|
+
// Always say why.
|
|
2181
|
+
if (finalText && !shouldDeliverFinal) {
|
|
2182
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
|
|
2183
|
+
+ `Final not delivered (${finalText.length} chars): `
|
|
2184
|
+
+ describeUndeliveredClaudeFinal({
|
|
2185
|
+
turn: completedInput,
|
|
2186
|
+
finalizedTurnKeys: session.finalizedTurnKeys,
|
|
2187
|
+
interruptedTurnKeys: session.interruptedTurnKeys,
|
|
2188
|
+
}));
|
|
2189
|
+
}
|
|
1693
2190
|
const finalDelivered = finalText && shouldDeliverFinal
|
|
1694
2191
|
? await deliverFinalReply(finalText, completedInput, Boolean(failureNotice))
|
|
1695
2192
|
: true;
|
|
@@ -1700,7 +2197,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1700
2197
|
totalTokens: usage.totalTokens,
|
|
1701
2198
|
maxTokens: usage.maxTokens,
|
|
1702
2199
|
};
|
|
1703
|
-
|
|
2200
|
+
if (finalDelivered)
|
|
2201
|
+
releaseTurnSlotFor(completedInput);
|
|
1704
2202
|
writeState();
|
|
1705
2203
|
}
|
|
1706
2204
|
catch {
|
|
@@ -1718,6 +2216,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1718
2216
|
scheduleFinalHandoffReset();
|
|
1719
2217
|
}
|
|
1720
2218
|
else {
|
|
2219
|
+
// Nothing durable is coming, so retire the live bubble here.
|
|
2220
|
+
// The delivering path does this after a handoff delay; without
|
|
2221
|
+
// it a gated turn leaves an orphaned /streaming node behind.
|
|
2222
|
+
clearStreaming().catch(() => { });
|
|
1721
2223
|
resetTurnToIdle();
|
|
1722
2224
|
}
|
|
1723
2225
|
}
|
|
@@ -1771,20 +2273,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1771
2273
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial ultracode:`, err);
|
|
1772
2274
|
}
|
|
1773
2275
|
}
|
|
1774
|
-
if (!resumeSessionId) {
|
|
1775
|
-
// New session — send seed message to activate streaming input mode
|
|
1776
|
-
sendInput(createClaudeInputEnvelope({
|
|
1777
|
-
kind: 'seed',
|
|
1778
|
-
msg: {
|
|
1779
|
-
type: 'user',
|
|
1780
|
-
message: {
|
|
1781
|
-
role: 'user',
|
|
1782
|
-
content: 'You are assisting in an ongoing chat. Reply naturally to the latest participant.',
|
|
1783
|
-
},
|
|
1784
|
-
parent_tool_use_id: null,
|
|
1785
|
-
},
|
|
1786
|
-
}));
|
|
1787
|
-
}
|
|
1788
2276
|
// Refresh the canonical runtime descriptor after Claude reports supported models.
|
|
1789
2277
|
try {
|
|
1790
2278
|
const models = await q.supportedModels();
|
|
@@ -2297,8 +2785,12 @@ export async function main() {
|
|
|
2297
2785
|
}
|
|
2298
2786
|
async function resetRuntimeSession(session) {
|
|
2299
2787
|
const conversationId = session.conversationId;
|
|
2788
|
+
// `dispatchingInput` excludes the dispatch window: the slot is reserved but
|
|
2789
|
+
// the SDK has nothing in flight, so there is nothing to interrupt. The
|
|
2790
|
+
// dispatch itself bails on `session.closed` rather than pushing an input
|
|
2791
|
+
// into a session that is being torn down.
|
|
2300
2792
|
const sdkTurnActive = (session.state.state === 'running'
|
|
2301
|
-
|| session.state.state === 'requires_action') && !session.pendingFinalDelivery;
|
|
2793
|
+
|| session.state.state === 'requires_action') && !session.pendingFinalDelivery && !session.dispatchingInput;
|
|
2302
2794
|
session.controlInterruptPending = sdkTurnActive;
|
|
2303
2795
|
const interruptResult = await confirmClaudeInterrupt({
|
|
2304
2796
|
active: sdkTurnActive,
|
|
@@ -2918,10 +3410,25 @@ export async function main() {
|
|
|
2918
3410
|
}
|
|
2919
3411
|
return;
|
|
2920
3412
|
}
|
|
2921
|
-
const
|
|
2922
|
-
|
|
3413
|
+
const action = decideClaudeControlSignalAction({
|
|
3414
|
+
type: event.type,
|
|
3415
|
+
dispatching: session.dispatchingInput !== null,
|
|
3416
|
+
sessionState: session.state.state,
|
|
3417
|
+
queueDepth: session.pendingInputs.length,
|
|
3418
|
+
});
|
|
3419
|
+
if (action === 'defer') {
|
|
3420
|
+
// Reserved but not yet dispatched: there is nothing in the SDK to stop
|
|
3421
|
+
// YET. Consuming the signal here would make a Stop pressed in the
|
|
3422
|
+
// accept/artifact window do nothing while the turn it was aimed at ran to
|
|
3423
|
+
// completion; the poller revisits this a cycle later instead.
|
|
3424
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] `
|
|
3425
|
+
+ `${event.type} signal arrived during turn dispatch; retrying it`);
|
|
3426
|
+
return { defer: true };
|
|
3427
|
+
}
|
|
3428
|
+
if (action === 'consume') {
|
|
2923
3429
|
return; // Nothing to interrupt — consume the stale signal
|
|
2924
3430
|
}
|
|
3431
|
+
const isActive = isClaudeTurnSlotReserved(session.state.state);
|
|
2925
3432
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
|
|
2926
3433
|
const sdkTurnActive = isActive && !session.pendingFinalDelivery;
|
|
2927
3434
|
session.controlInterruptPending = sdkTurnActive;
|