@canonmsg/claude-code-plugin 0.28.1 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +44 -8
- package/dist/host.js +571 -90
- package/dist/session-state.d.ts +498 -2
- package/dist/session-state.js +804 -6
- package/package.json +6 -6
- package/skills/configure/SKILL.md +31 -22
- package/skills/register/SKILL.md +8 -3
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, claudeInputOwnsTurnSlot, rememberDispatchedClaudeInput, takeClaudeResultOwner, 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';
|
|
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) {
|
|
@@ -677,7 +677,7 @@ function buildClaudeRuntimeInfo(input) {
|
|
|
677
677
|
}
|
|
678
678
|
// ── Constants ───────────────────────────────────────────────────────
|
|
679
679
|
const MAX_SESSIONS = 5;
|
|
680
|
-
|
|
680
|
+
// IDLE_TIMEOUT_MS (30 minutes) is shared with the other coding-agent hosts.
|
|
681
681
|
const STREAMING_THROTTLE_MS = 250;
|
|
682
682
|
const HEARTBEAT_MS = 30_000;
|
|
683
683
|
const IDLE_CHECK_MS = 60_000;
|
|
@@ -685,7 +685,11 @@ const CONTROL_POLL_MS = 2000;
|
|
|
685
685
|
const IDLE_CONTROL_POLL_MS = 10_000;
|
|
686
686
|
const CONTROL_POLL_JITTER_MS = 1_000;
|
|
687
687
|
const FINAL_DELIVERY_RETRY_MS = 30_000;
|
|
688
|
-
|
|
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;
|
|
689
693
|
const CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID = 'claude-recovery-cursor-v1';
|
|
690
694
|
const CLAUDE_RUNTIME_CAPABILITIES = {
|
|
691
695
|
...DEFAULT_RUNTIME_CAPABILITIES,
|
|
@@ -1120,6 +1124,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1120
1124
|
finalDeliveryTimer: null,
|
|
1121
1125
|
pendingInputs: [],
|
|
1122
1126
|
activeInput: null,
|
|
1127
|
+
dispatchingInput: null,
|
|
1123
1128
|
finalizedTurnKeys: new Set(),
|
|
1124
1129
|
interruptedTurnKeys: new Set(),
|
|
1125
1130
|
pendingFinalText: null,
|
|
@@ -1130,6 +1135,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1130
1135
|
currentTurnId: null,
|
|
1131
1136
|
currentTurnOpenedAt: null,
|
|
1132
1137
|
currentTurnUpdatedAt: null,
|
|
1138
|
+
turnActivity: createClaudeTurnActivityState(),
|
|
1133
1139
|
activeSelfContextId: null,
|
|
1134
1140
|
lastAcceptedIntent: null,
|
|
1135
1141
|
lastActivity: Date.now(),
|
|
@@ -1183,6 +1189,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1183
1189
|
return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
|
|
1184
1190
|
}
|
|
1185
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);
|
|
1186
1196
|
return streamingOutput.clear().catch(() => { });
|
|
1187
1197
|
}
|
|
1188
1198
|
function markInputCompleted(input) {
|
|
@@ -1203,9 +1213,20 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1203
1213
|
}
|
|
1204
1214
|
config.onInputCompleted?.(input.sourceMessageId);
|
|
1205
1215
|
}
|
|
1206
|
-
|
|
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) {
|
|
1207
1227
|
const turnId = session.currentTurnId ?? `claude-${conversationId}`;
|
|
1208
|
-
|
|
1228
|
+
const blocks = prepareTurnTrailForDelivery(streamingOutput.getBlocks(), willChunk);
|
|
1229
|
+
return buildBoundedTurnTrail(blocks).map((block) => ({ ...block, turnId }));
|
|
1209
1230
|
}
|
|
1210
1231
|
function clearIdleResetTimer() {
|
|
1211
1232
|
if (!session.idleResetTimer)
|
|
@@ -1219,10 +1240,31 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1219
1240
|
clearTimeout(session.finalDeliveryTimer);
|
|
1220
1241
|
session.finalDeliveryTimer = null;
|
|
1221
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
|
+
*/
|
|
1222
1263
|
function resetTurnToIdle() {
|
|
1223
1264
|
if (session.closed)
|
|
1224
1265
|
return;
|
|
1225
1266
|
clearFinalDeliveryTimer();
|
|
1267
|
+
clearStreaming().catch(() => { });
|
|
1226
1268
|
resetClaudeCompletedTurnState(session);
|
|
1227
1269
|
stopVisibleWorkSignal();
|
|
1228
1270
|
writeState();
|
|
@@ -1238,23 +1280,23 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1238
1280
|
resetTurnToIdle();
|
|
1239
1281
|
}, FINAL_MESSAGE_HANDOFF_MS);
|
|
1240
1282
|
}
|
|
1241
|
-
function markPendingFinalDelivery(finalText, turn, suppressAutoReply = false) {
|
|
1283
|
+
function markPendingFinalDelivery(finalText, turn, suppressAutoReply = false, progress = {}) {
|
|
1242
1284
|
clearIdleResetTimer();
|
|
1243
|
-
const retryCount = session.pendingFinalDelivery?.turnKey === turn.turnKey
|
|
1244
|
-
? session.pendingFinalDelivery.retryCount
|
|
1245
|
-
: 0;
|
|
1246
1285
|
session.pendingFinalText = finalText;
|
|
1247
|
-
|
|
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,
|
|
1248
1290
|
turnKey: turn.turnKey,
|
|
1249
1291
|
text: finalText,
|
|
1250
|
-
retryCount,
|
|
1251
|
-
...(suppressAutoReply ? { suppressAutoReply: true } : {}),
|
|
1252
1292
|
messageId: buildClaudeFinalMessageId({
|
|
1253
1293
|
agentId,
|
|
1254
1294
|
conversationId,
|
|
1255
1295
|
turnKey: turn.turnKey,
|
|
1256
1296
|
}),
|
|
1257
|
-
|
|
1297
|
+
suppressAutoReply,
|
|
1298
|
+
...progress,
|
|
1299
|
+
});
|
|
1258
1300
|
session.state.state = 'running';
|
|
1259
1301
|
session.turnState = 'streaming';
|
|
1260
1302
|
stopVisibleWorkSignal();
|
|
@@ -1263,6 +1305,119 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1263
1305
|
writeTurn();
|
|
1264
1306
|
typingSignals.start(conversationId, 'typing').catch(() => { });
|
|
1265
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
|
+
}
|
|
1266
1421
|
async function deliverFinalReply(finalText, turn = session.activeInput, suppressAutoReply = false) {
|
|
1267
1422
|
if (!shouldDeliverClaudeFinal({
|
|
1268
1423
|
turn,
|
|
@@ -1279,33 +1434,94 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1279
1434
|
conversationId,
|
|
1280
1435
|
turnKey: deliverTurn.turnKey,
|
|
1281
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;
|
|
1282
1463
|
try {
|
|
1283
|
-
await
|
|
1464
|
+
const { messageIds } = await sendMessageWithRetryChunked(client, conversationId, finalText, {
|
|
1284
1465
|
messageId: finalMessageId,
|
|
1285
|
-
...(
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
suppressAutoReply,
|
|
1295
|
-
}),
|
|
1296
|
-
});
|
|
1297
|
-
session.finalizedTurnKeys.add(deliverTurn.turnKey);
|
|
1298
|
-
markInputCompleted(deliverTurn);
|
|
1299
|
-
session.state.state = 'idle';
|
|
1300
|
-
session.pendingFinalDelivery = null;
|
|
1301
|
-
clearFinalDeliveryTimer();
|
|
1302
|
-
writeState();
|
|
1303
|
-
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
|
+
: ''})`);
|
|
1304
1475
|
return true;
|
|
1305
1476
|
}
|
|
1306
1477
|
catch (err) {
|
|
1307
|
-
|
|
1308
|
-
|
|
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);
|
|
1486
|
+
if (failure === 'permanent') {
|
|
1487
|
+
// Re-sending is pointless — the same request fails the same way. Surface
|
|
1488
|
+
// what can be surfaced and finish the turn instead of burning the retry
|
|
1489
|
+
// budget and dropping the answer in silence.
|
|
1490
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply permanently rejected:`, err);
|
|
1491
|
+
// Surface FIRST, mark delivered SECOND. markFinalTurnDelivered persists
|
|
1492
|
+
// the inbound recovery cursor (so a restart will not replay this
|
|
1493
|
+
// message) and reopens the session to new input; neither may happen
|
|
1494
|
+
// while delivery is still unresolved.
|
|
1495
|
+
const outcome = await surfaceUndeliverableFinal({
|
|
1496
|
+
finalText,
|
|
1497
|
+
turn: deliverTurn,
|
|
1498
|
+
finalMessageId,
|
|
1499
|
+
suppressAutoReply,
|
|
1500
|
+
selfContextId,
|
|
1501
|
+
chunked: willChunk,
|
|
1502
|
+
// The notice only claims the reply "stops short" when parts of it
|
|
1503
|
+
// really are in the conversation — now known exactly, instead of
|
|
1504
|
+
// being inferred from "this was a chunked send".
|
|
1505
|
+
partiallyDelivered: deliveredMessageIds.length > 0,
|
|
1506
|
+
error: cause,
|
|
1507
|
+
});
|
|
1508
|
+
if (outcome === 'none') {
|
|
1509
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
|
|
1510
|
+
+ 'Nothing durable could be sent for this turn; the streamed text is the only record left');
|
|
1511
|
+
}
|
|
1512
|
+
markFinalTurnDelivered(deliverTurn);
|
|
1513
|
+
return true;
|
|
1514
|
+
}
|
|
1515
|
+
markPendingFinalDelivery(finalText, deliverTurn, suppressAutoReply, {
|
|
1516
|
+
metadata,
|
|
1517
|
+
selfContextId,
|
|
1518
|
+
deliveredMessageIds,
|
|
1519
|
+
lastErrorMessage: cause instanceof Error ? cause.message : String(cause),
|
|
1520
|
+
});
|
|
1521
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply`
|
|
1522
|
+
+ `${deliveredMessageIds.length > 0
|
|
1523
|
+
? ` after ${deliveredMessageIds.length} of ${chunkedFailure?.totalParts ?? '?'} parts`
|
|
1524
|
+
: ''}:`, err);
|
|
1309
1525
|
return false;
|
|
1310
1526
|
}
|
|
1311
1527
|
}
|
|
@@ -1351,9 +1567,29 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1351
1567
|
if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
|
|
1352
1568
|
return;
|
|
1353
1569
|
if (pending.retryCount >= MAX_FINAL_DELIVERY_RETRIES) {
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1570
|
+
const deliveredParts = pending.deliveredMessageIds?.length ?? 0;
|
|
1571
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply delivery exhausted after bounded retries`
|
|
1572
|
+
+ `${deliveredParts > 0 ? ` with ${deliveredParts} part(s) delivered` : ''}`);
|
|
1573
|
+
// Part of the answer IS in the conversation and the tail is never coming;
|
|
1574
|
+
// the notice says it stops short instead of leaving the last delivered
|
|
1575
|
+
// part looking like the end. The pending record stays set until the turn
|
|
1576
|
+
// is actually retired, so inbound messages keep queueing across the send.
|
|
1577
|
+
void runClaudeExhaustedFinalDelivery(session, pending, {
|
|
1578
|
+
sendNotice: (notice) => sendUndeliverableFinalNotice({
|
|
1579
|
+
finalMessageId: pending.messageId,
|
|
1580
|
+
selfContextId: pending.selfContextId,
|
|
1581
|
+
notice,
|
|
1582
|
+
}),
|
|
1583
|
+
retireTurn: () => {
|
|
1584
|
+
// Hand the answer to the server-side safety net: clearing /streaming
|
|
1585
|
+
// fires onStreamingCleared, which persists the streamed text as a
|
|
1586
|
+
// durable progress message when the turn has no turn_complete.
|
|
1587
|
+
// Without this the live bubble was torn down by the idle reset and
|
|
1588
|
+
// the work was lost.
|
|
1589
|
+
clearStreaming().catch(() => { });
|
|
1590
|
+
resetTurnToIdle();
|
|
1591
|
+
},
|
|
1592
|
+
});
|
|
1357
1593
|
return;
|
|
1358
1594
|
}
|
|
1359
1595
|
pending.retryCount += 1;
|
|
@@ -1443,10 +1679,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1443
1679
|
writeState();
|
|
1444
1680
|
}
|
|
1445
1681
|
function writeTurn() {
|
|
1446
|
-
const isOpenTurn = session.turnState
|
|
1447
|
-
|| session.turnState === 'streaming'
|
|
1448
|
-
|| session.turnState === 'tool'
|
|
1449
|
-
|| session.turnState === 'waiting_input';
|
|
1682
|
+
const isOpenTurn = isOpenClaudeTurnState(session.turnState);
|
|
1450
1683
|
if (isOpenTurn) {
|
|
1451
1684
|
session.currentTurnUpdatedAt = Date.now();
|
|
1452
1685
|
}
|
|
@@ -1489,10 +1722,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1489
1722
|
async function prepareArtifactsForInput(input) {
|
|
1490
1723
|
if (input.kind !== 'canon')
|
|
1491
1724
|
return;
|
|
1492
|
-
|
|
1493
|
-
session.currentTurnId = randomUUID();
|
|
1494
|
-
session.currentTurnOpenedAt = Date.now();
|
|
1495
|
-
}
|
|
1725
|
+
// The turn id is minted by openTurn(), which runs before this.
|
|
1496
1726
|
if (input.artifactRoutingMode === 'workspace-generated') {
|
|
1497
1727
|
input.artifactBaseline = await captureTurnArtifactSnapshot({ cwd: session.cwd }).catch((error) => {
|
|
1498
1728
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact snapshot failed:`, error instanceof Error ? error.message : error);
|
|
@@ -1501,24 +1731,149 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1501
1731
|
return;
|
|
1502
1732
|
}
|
|
1503
1733
|
}
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1734
|
+
/**
|
|
1735
|
+
* Make the turn visible the moment it is dequeued, before the SDK has said
|
|
1736
|
+
* anything.
|
|
1737
|
+
*
|
|
1738
|
+
* Until now the first publish of any kind came from the SDK echoing
|
|
1739
|
+
* `session_state_changed: running`, and the first thing the reader saw came
|
|
1740
|
+
* later still, with the first streamed token — so an accepted message looked
|
|
1741
|
+
* like nothing had happened. Opening synchronously here (Codex does the same
|
|
1742
|
+
* at its dequeue) gives the reader the thinking header, the typing dots and a
|
|
1743
|
+
* live turn row immediately; the seed is status-only because text written to
|
|
1744
|
+
* `/streaming` is rendered as the agent's speech.
|
|
1745
|
+
*/
|
|
1746
|
+
function openTurn(input) {
|
|
1747
|
+
// Only Canon turns are user-facing; internal envelopes stay invisible.
|
|
1748
|
+
if (session.closed || !claudeInputOwnsTurnSlot(input))
|
|
1749
|
+
return;
|
|
1750
|
+
clearIdleResetTimer();
|
|
1751
|
+
openClaudeTurn(session);
|
|
1752
|
+
startVisibleWorkSignal();
|
|
1753
|
+
writeTurn();
|
|
1754
|
+
seedTurnStreaming();
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* Open `/streaming` for a turn that is starting: drop whatever the previous
|
|
1758
|
+
* turn left there, then seed the empty thinking node.
|
|
1759
|
+
*
|
|
1760
|
+
* The clear is what makes the seed empty. `startThinking('')` resets the
|
|
1761
|
+
* node's TEXT and nothing else — the controller's block array survives until
|
|
1762
|
+
* `clear()` — and `openTurn` cancels (`clearIdleResetTimer`) the handoff reset
|
|
1763
|
+
* that would otherwise have cleared it. So a turn opened inside the 750ms
|
|
1764
|
+
* handoff window would republish the FINISHED turn's receipts and plan card
|
|
1765
|
+
* under its own id, `openClaudeTurn` having just dropped the bookkeeping that
|
|
1766
|
+
* could still complete them, and `getFinalTurnTrail` would then hang them on
|
|
1767
|
+
* the next final's persisted trail.
|
|
1768
|
+
*
|
|
1769
|
+
* Safe with respect to functions' `onStreamingCleared`: the node being
|
|
1770
|
+
* deleted still carries the finished turn's id, which by definition has a
|
|
1771
|
+
* `turn_complete` message (its final was delivered), so the fallback skips as
|
|
1772
|
+
* `turn_complete_exists`. On every other path the node is already gone and
|
|
1773
|
+
* the delete has nothing to fire on.
|
|
1774
|
+
*/
|
|
1775
|
+
function seedTurnStreaming() {
|
|
1776
|
+
clearStreaming().catch(() => { });
|
|
1777
|
+
streamingOutput.startThinking('').catch(() => { });
|
|
1778
|
+
}
|
|
1779
|
+
function applyTrailCommands(commands) {
|
|
1780
|
+
for (const command of commands) {
|
|
1781
|
+
switch (command.op) {
|
|
1782
|
+
case 'add':
|
|
1783
|
+
streamingOutput.addBlock({
|
|
1784
|
+
id: command.id,
|
|
1785
|
+
kind: command.kind,
|
|
1786
|
+
...(command.status ? { status: command.status } : {}),
|
|
1787
|
+
...(command.title ? { title: command.title } : {}),
|
|
1788
|
+
...(command.text ? { text: command.text } : {}),
|
|
1789
|
+
}).catch(() => { });
|
|
1790
|
+
break;
|
|
1791
|
+
case 'update':
|
|
1792
|
+
streamingOutput.updateBlock(command.id, {
|
|
1793
|
+
...(command.title !== undefined ? { title: command.title } : {}),
|
|
1794
|
+
...(command.summary !== undefined ? { summary: command.summary } : {}),
|
|
1795
|
+
}).catch(() => { });
|
|
1796
|
+
break;
|
|
1797
|
+
case 'complete':
|
|
1798
|
+
streamingOutput.completeBlock(command.id, command.summary === undefined ? undefined : { summary: command.summary }).catch(() => { });
|
|
1799
|
+
break;
|
|
1800
|
+
case 'fail':
|
|
1801
|
+
streamingOutput.failBlock(command.id, { summary: command.summary }).catch(() => { });
|
|
1802
|
+
break;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Settle an input that never reached Claude.
|
|
1808
|
+
*
|
|
1809
|
+
* Both ways out of the dispatch window used to just drop it: the sender's
|
|
1810
|
+
* message stayed 'accepted' forever, the id stayed in the host's in-flight
|
|
1811
|
+
* set (it is in neither `pendingInputs` nor `activeInput`, so no reset path
|
|
1812
|
+
* settles it either), and the reader watched a 'thinking' turn appear and
|
|
1813
|
+
* vanish with no answer. Telling the sender it was rejected is the honest
|
|
1814
|
+
* ending, and it matches what every other undelivered input gets.
|
|
1815
|
+
*/
|
|
1816
|
+
function abandonDispatchedInput(input, reason, error) {
|
|
1817
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
|
|
1818
|
+
+ `Turn dispatch ${reason === 'closed' ? 'abandoned: session closed mid-dispatch' : 'failed'} `
|
|
1819
|
+
+ `before the input reached Claude${input.sourceMessageId ? ` (message ${input.sourceMessageId})` : ''}${error === undefined ? '' : ':'}`, ...(error === undefined ? [] : [error instanceof Error ? error.message : error]));
|
|
1820
|
+
if (input.markAccepted && input.sourceMessageId) {
|
|
1821
|
+
void client
|
|
1822
|
+
.updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected')
|
|
1823
|
+
.catch(() => { });
|
|
1824
|
+
}
|
|
1825
|
+
markInputCompleted(input);
|
|
1826
|
+
// resetTurnToIdle releases the slot, tears the published turn down (typing
|
|
1827
|
+
// signal, seeded /streaming, turn state) and drains the queue. On a closed
|
|
1828
|
+
// session it is a no-op, which is correct: the teardown owns that path.
|
|
1829
|
+
resetTurnToIdle();
|
|
1830
|
+
}
|
|
1831
|
+
function startCanonInput(input) {
|
|
1832
|
+
return dispatchClaudeInput(session, input, {
|
|
1833
|
+
openTurn,
|
|
1834
|
+
markAccepted: (dispatched) => markQueuedMessageAccepted(dispatched.sourceMessageId, dispatched.markAccepted),
|
|
1835
|
+
prepareArtifacts: prepareArtifactsForInput,
|
|
1836
|
+
send: sendInput,
|
|
1837
|
+
abandon: abandonDispatchedInput,
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Fire-and-forget dispatch. `startCanonInput` handles its own failures, so
|
|
1842
|
+
* this only catches what could throw before or after them — without it a
|
|
1843
|
+
* rejection here would surface as an unhandled rejection and take the host
|
|
1844
|
+
* process with it under `--unhandled-rejections=strict`.
|
|
1845
|
+
*/
|
|
1846
|
+
function dispatchCanonInput(input) {
|
|
1847
|
+
startCanonInput(input).catch((error) => {
|
|
1848
|
+
if (session.dispatchingInput === input)
|
|
1849
|
+
session.dispatchingInput = null;
|
|
1850
|
+
abandonDispatchedInput(input, 'error', error);
|
|
1851
|
+
});
|
|
1509
1852
|
}
|
|
1510
1853
|
function drainPendingInput() {
|
|
1511
|
-
if (
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1854
|
+
if (!canDrainClaudeQueuedInput({
|
|
1855
|
+
closed: session.closed,
|
|
1856
|
+
controlInterruptPending: session.controlInterruptPending,
|
|
1857
|
+
sessionState: session.state.state,
|
|
1858
|
+
queueDepth: session.pendingInputs.length,
|
|
1859
|
+
}))
|
|
1515
1860
|
return;
|
|
1516
1861
|
const next = session.pendingInputs.shift();
|
|
1517
1862
|
if (!next)
|
|
1518
1863
|
return;
|
|
1519
1864
|
session.lastAcceptedIntent = next.intent;
|
|
1520
|
-
writeTurn()
|
|
1521
|
-
|
|
1865
|
+
// No writeTurn() here: it would publish the still-'idle' state (with a
|
|
1866
|
+
// completedAt) for a turn that is starting. openTurn() publishes 'thinking'
|
|
1867
|
+
// synchronously inside startCanonInput, carrying the new queue depth.
|
|
1868
|
+
dispatchCanonInput(next);
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* True when an input has actually reached the SDK. Everything an interrupt
|
|
1872
|
+
* acts on is keyed to this: in the dispatch window the input is still in the
|
|
1873
|
+
* host's hands, so there is nothing for `query.interrupt()` to stop.
|
|
1874
|
+
*/
|
|
1875
|
+
function hasInterruptibleSdkInput() {
|
|
1876
|
+
return session.activeInput !== null && session.dispatchingInput === null;
|
|
1522
1877
|
}
|
|
1523
1878
|
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => {
|
|
1524
1879
|
session.lastActivity = Date.now();
|
|
@@ -1532,17 +1887,28 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1532
1887
|
requestingUserId,
|
|
1533
1888
|
artifactRoutingMode,
|
|
1534
1889
|
});
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1890
|
+
const decision = decideClaudeInboundDispatch({
|
|
1891
|
+
intent,
|
|
1892
|
+
sessionState: session.state.state,
|
|
1893
|
+
hasPendingFinalDelivery: Boolean(session.pendingFinalDelivery),
|
|
1894
|
+
hasInterruptibleInput: hasInterruptibleSdkInput(),
|
|
1895
|
+
});
|
|
1896
|
+
if (decision === 'queue' || decision === 'queue-front') {
|
|
1897
|
+
if (decision === 'queue-front') {
|
|
1898
|
+
session.pendingInputs.unshift(input);
|
|
1899
|
+
// An interrupt that arrived while the previous message was still being
|
|
1900
|
+
// dispatched: nothing is in the SDK to stop, so it takes the front of
|
|
1901
|
+
// the queue and starts the moment that turn ends.
|
|
1902
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
|
|
1903
|
+
+ 'Interrupt arrived during turn dispatch; queued at the front instead');
|
|
1904
|
+
}
|
|
1905
|
+
else {
|
|
1906
|
+
session.pendingInputs.push(input);
|
|
1907
|
+
}
|
|
1542
1908
|
writeTurn();
|
|
1543
1909
|
return;
|
|
1544
1910
|
}
|
|
1545
|
-
if (
|
|
1911
|
+
if (decision === 'interrupt') {
|
|
1546
1912
|
session.pendingInputs.unshift(input);
|
|
1547
1913
|
writeTurn();
|
|
1548
1914
|
const interruptedInput = session.activeInput;
|
|
@@ -1561,10 +1927,27 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1561
1927
|
return;
|
|
1562
1928
|
}
|
|
1563
1929
|
session.lastAcceptedIntent = intent;
|
|
1564
|
-
|
|
1565
|
-
|
|
1930
|
+
// Same as in drainPendingInput: openTurn() does the publish, as 'thinking'.
|
|
1931
|
+
dispatchCanonInput(input);
|
|
1566
1932
|
};
|
|
1567
1933
|
writeTurn();
|
|
1934
|
+
// ── Turn activity (margin trail) ──
|
|
1935
|
+
/** True when this `content_block_stop` closes a tool call this turn claimed. */
|
|
1936
|
+
function isToolBlockStop(event) {
|
|
1937
|
+
if (takeClaudeToolBlockIdByIndex(session.turnActivity, event?.index))
|
|
1938
|
+
return true;
|
|
1939
|
+
// Older runtimes may omit the index; fall back to the coarse flag rather
|
|
1940
|
+
// than leaving the turn stuck in 'tool'.
|
|
1941
|
+
return typeof event?.index !== 'number' && session.toolInProgress;
|
|
1942
|
+
}
|
|
1943
|
+
// The plan card is deliberately NOT pushed into the live bubble the way Codex
|
|
1944
|
+
// does: Claude streams real speech there, and the plan belongs in the margin.
|
|
1945
|
+
function applyAssistantToolCalls(content) {
|
|
1946
|
+
applyTrailCommands(planClaudeAssistantTrail(session.turnActivity, content, session.currentTurnId ?? conversationId));
|
|
1947
|
+
}
|
|
1948
|
+
function applyToolResults(content) {
|
|
1949
|
+
applyTrailCommands(planClaudeToolResults(session.turnActivity, content));
|
|
1950
|
+
}
|
|
1568
1951
|
// ── Process SDK output messages ──
|
|
1569
1952
|
const processMessages = async () => {
|
|
1570
1953
|
try {
|
|
@@ -1584,6 +1967,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1584
1967
|
// the final. Gate on the owning turn instead of on arrival order.
|
|
1585
1968
|
if (!claudeInputOwnsTurnSlot(session.activeInput))
|
|
1586
1969
|
break;
|
|
1970
|
+
// A subagent's message is not this agent's speech. Its narration is
|
|
1971
|
+
// already kept out of the live bubble and its calls out of the
|
|
1972
|
+
// trail, so it must not become `pendingFinalText` either: that is
|
|
1973
|
+
// the fallback the final falls back to when the result carries no
|
|
1974
|
+
// text, and delivering it would surface as the answer text nobody
|
|
1975
|
+
// ever saw streamed.
|
|
1976
|
+
if (!isClaudeMainTurnMessage(msg.parent_tool_use_id))
|
|
1977
|
+
break;
|
|
1978
|
+
// The assistant message is the first place the tool call's typed
|
|
1979
|
+
// INPUT is visible — content_block_start carries only the name. It
|
|
1980
|
+
// is what turns "Bash" into "Running: npm test" and TodoWrite into
|
|
1981
|
+
// the plan card.
|
|
1982
|
+
if (!isActiveTurnInterrupted()) {
|
|
1983
|
+
applyAssistantToolCalls(msg.message?.content ?? []);
|
|
1984
|
+
}
|
|
1587
1985
|
const textBlocks = (msg.message?.content ?? [])
|
|
1588
1986
|
.filter((b) => b.type === 'text')
|
|
1589
1987
|
.map((b) => b.text);
|
|
@@ -1599,23 +1997,36 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1599
1997
|
stopVisibleWorkSignal();
|
|
1600
1998
|
break;
|
|
1601
1999
|
}
|
|
2000
|
+
// A subagent's own stream stays out of the turn ENTIRELY — its tool
|
|
2001
|
+
// calls and its text alike. The Task row stands for the whole
|
|
2002
|
+
// subagent; its calls would flood the trail (and the 20-block
|
|
2003
|
+
// budget the final's trail is bounded to), and its narration is not
|
|
2004
|
+
// this agent's speech: streamed into the live bubble it collides
|
|
2005
|
+
// with the main agent's own text segments and corrupts the answer
|
|
2006
|
+
// being written.
|
|
2007
|
+
if (!isClaudeMainTurnMessage(msg.parent_tool_use_id))
|
|
2008
|
+
break;
|
|
1602
2009
|
if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
|
2010
|
+
// Claimed per CALL, keyed by the SDK's tool_use id. The old id was
|
|
2011
|
+
// the turn id, so every call in a turn wrote over the same row.
|
|
2012
|
+
const commands = planClaudeToolCallStart(session.turnActivity, {
|
|
2013
|
+
toolUseId: event.content_block?.id,
|
|
2014
|
+
index: event.index,
|
|
2015
|
+
name: event.content_block?.name,
|
|
2016
|
+
turnId: session.currentTurnId ?? conversationId,
|
|
2017
|
+
});
|
|
1603
2018
|
session.toolInProgress = true;
|
|
1604
2019
|
session.turnState = 'tool';
|
|
1605
|
-
|
|
1606
|
-
id: `tool:${session.currentTurnId ?? conversationId}`,
|
|
1607
|
-
kind: 'tool',
|
|
1608
|
-
status: 'running',
|
|
1609
|
-
title: typeof event.content_block?.name === 'string'
|
|
1610
|
-
? event.content_block.name
|
|
1611
|
-
: 'Claude tool use',
|
|
1612
|
-
}).catch(() => { });
|
|
2020
|
+
applyTrailCommands(commands);
|
|
1613
2021
|
startVisibleWorkSignal();
|
|
1614
2022
|
writeTurn();
|
|
1615
2023
|
}
|
|
1616
|
-
else if (event?.type === 'content_block_stop' &&
|
|
2024
|
+
else if (event?.type === 'content_block_stop' && isToolBlockStop(event)) {
|
|
2025
|
+
// The MODEL has finished writing the call; the tool has not run
|
|
2026
|
+
// yet. The row therefore stays 'running' until its tool_result
|
|
2027
|
+
// arrives — "completed" in the margin has to mean the tool
|
|
2028
|
+
// returned, which is what the old code got wrong.
|
|
1617
2029
|
session.toolInProgress = false;
|
|
1618
|
-
streamingOutput.completeBlock(`tool:${session.currentTurnId ?? conversationId}`).catch(() => { });
|
|
1619
2030
|
if (session.state.state === 'running') {
|
|
1620
2031
|
session.turnState = 'thinking';
|
|
1621
2032
|
startVisibleWorkSignal();
|
|
@@ -1632,21 +2043,71 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1632
2043
|
}
|
|
1633
2044
|
break;
|
|
1634
2045
|
}
|
|
2046
|
+
case 'user': {
|
|
2047
|
+
// Where a tool call's OUTCOME arrives. Without this the margin's
|
|
2048
|
+
// receipts never resolved: rows were "completed" the moment the
|
|
2049
|
+
// model finished writing the call, and a failing tool looked
|
|
2050
|
+
// identical to a successful one.
|
|
2051
|
+
if (!isClaudeMainTurnMessage(msg.parent_tool_use_id))
|
|
2052
|
+
break;
|
|
2053
|
+
if (!claudeInputOwnsTurnSlot(session.activeInput))
|
|
2054
|
+
break;
|
|
2055
|
+
if (isActiveTurnInterrupted())
|
|
2056
|
+
break;
|
|
2057
|
+
applyToolResults(msg.message?.content);
|
|
2058
|
+
break;
|
|
2059
|
+
}
|
|
2060
|
+
case 'tool_progress': {
|
|
2061
|
+
// A long call would otherwise sit as a bare running row while the
|
|
2062
|
+
// typing signal quietly aged out.
|
|
2063
|
+
if (!isClaudeMainTurnMessage(msg.parent_tool_use_id))
|
|
2064
|
+
break;
|
|
2065
|
+
if (isActiveTurnInterrupted())
|
|
2066
|
+
break;
|
|
2067
|
+
applyTrailCommands(planClaudeToolProgress(session.turnActivity, {
|
|
2068
|
+
toolUseId: msg.tool_use_id,
|
|
2069
|
+
elapsedSeconds: msg.elapsed_time_seconds,
|
|
2070
|
+
}));
|
|
2071
|
+
refreshVisibleWorkSignal();
|
|
2072
|
+
break;
|
|
2073
|
+
}
|
|
1635
2074
|
case 'system': {
|
|
1636
2075
|
const subtype = msg.subtype;
|
|
1637
2076
|
if (subtype === 'session_state_changed') {
|
|
1638
2077
|
const state = msg.state;
|
|
2078
|
+
// The echo shares one field with the host's own reservation and
|
|
2079
|
+
// lags behind it: the 'idle' closing turn A arrives after A's
|
|
2080
|
+
// result, and the queue is drained INSIDE that result handler, so
|
|
2081
|
+
// turn B can already hold the slot. Applying it there would free a
|
|
2082
|
+
// slot that is taken and stop the typing signal B just started.
|
|
2083
|
+
if (!shouldApplyClaudeEchoedSessionState({
|
|
2084
|
+
echoed: state,
|
|
2085
|
+
dispatching: session.dispatchingInput !== null,
|
|
2086
|
+
active: session.activeInput !== null,
|
|
2087
|
+
hasPendingFinalDelivery: Boolean(session.pendingFinalDelivery),
|
|
2088
|
+
})) {
|
|
2089
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
|
|
2090
|
+
+ 'Ignoring a stale idle echo: the host still holds the turn slot');
|
|
2091
|
+
break;
|
|
2092
|
+
}
|
|
1639
2093
|
session.state.state = state;
|
|
1640
2094
|
if (state === 'running') {
|
|
1641
2095
|
clearIdleResetTimer();
|
|
1642
|
-
if (!session.currentTurnId) {
|
|
1643
|
-
session.currentTurnId = randomUUID();
|
|
1644
|
-
session.currentTurnOpenedAt = Date.now();
|
|
1645
|
-
}
|
|
1646
2096
|
session.state.contextUsage = undefined;
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
2097
|
+
// Idempotent confirmation of a turn the host already opened —
|
|
2098
|
+
// re-seeding /streaming here would wipe the blocks and text the
|
|
2099
|
+
// turn has produced since. It still opens turns the host did not
|
|
2100
|
+
// start itself (an internal envelope, or a resumed session).
|
|
2101
|
+
if (shouldOpenClaudeTurnOnRunning(session.turnState)) {
|
|
2102
|
+
openClaudeTurn(session);
|
|
2103
|
+
seedTurnStreaming();
|
|
2104
|
+
}
|
|
2105
|
+
else if (session.turnState === 'waiting_input') {
|
|
2106
|
+
// An approval was answered: the same turn is working again,
|
|
2107
|
+
// so it leaves 'waiting_input' without being reopened.
|
|
2108
|
+
session.turnState = 'thinking';
|
|
2109
|
+
session.toolInProgress = false;
|
|
2110
|
+
}
|
|
1650
2111
|
startVisibleWorkSignal();
|
|
1651
2112
|
}
|
|
1652
2113
|
else if (state === 'idle') {
|
|
@@ -1731,7 +2192,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1731
2192
|
totalTokens: usage.totalTokens,
|
|
1732
2193
|
maxTokens: usage.maxTokens,
|
|
1733
2194
|
};
|
|
1734
|
-
|
|
2195
|
+
if (finalDelivered)
|
|
2196
|
+
releaseTurnSlotFor(completedInput);
|
|
1735
2197
|
writeState();
|
|
1736
2198
|
}
|
|
1737
2199
|
catch {
|
|
@@ -2318,8 +2780,12 @@ export async function main() {
|
|
|
2318
2780
|
}
|
|
2319
2781
|
async function resetRuntimeSession(session) {
|
|
2320
2782
|
const conversationId = session.conversationId;
|
|
2783
|
+
// `dispatchingInput` excludes the dispatch window: the slot is reserved but
|
|
2784
|
+
// the SDK has nothing in flight, so there is nothing to interrupt. The
|
|
2785
|
+
// dispatch itself bails on `session.closed` rather than pushing an input
|
|
2786
|
+
// into a session that is being torn down.
|
|
2321
2787
|
const sdkTurnActive = (session.state.state === 'running'
|
|
2322
|
-
|| session.state.state === 'requires_action') && !session.pendingFinalDelivery;
|
|
2788
|
+
|| session.state.state === 'requires_action') && !session.pendingFinalDelivery && !session.dispatchingInput;
|
|
2323
2789
|
session.controlInterruptPending = sdkTurnActive;
|
|
2324
2790
|
const interruptResult = await confirmClaudeInterrupt({
|
|
2325
2791
|
active: sdkTurnActive,
|
|
@@ -2939,10 +3405,25 @@ export async function main() {
|
|
|
2939
3405
|
}
|
|
2940
3406
|
return;
|
|
2941
3407
|
}
|
|
2942
|
-
const
|
|
2943
|
-
|
|
3408
|
+
const action = decideClaudeControlSignalAction({
|
|
3409
|
+
type: event.type,
|
|
3410
|
+
dispatching: session.dispatchingInput !== null,
|
|
3411
|
+
sessionState: session.state.state,
|
|
3412
|
+
queueDepth: session.pendingInputs.length,
|
|
3413
|
+
});
|
|
3414
|
+
if (action === 'defer') {
|
|
3415
|
+
// Reserved but not yet dispatched: there is nothing in the SDK to stop
|
|
3416
|
+
// YET. Consuming the signal here would make a Stop pressed in the
|
|
3417
|
+
// accept/artifact window do nothing while the turn it was aimed at ran to
|
|
3418
|
+
// completion; the poller revisits this a cycle later instead.
|
|
3419
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] `
|
|
3420
|
+
+ `${event.type} signal arrived during turn dispatch; retrying it`);
|
|
3421
|
+
return { defer: true };
|
|
3422
|
+
}
|
|
3423
|
+
if (action === 'consume') {
|
|
2944
3424
|
return; // Nothing to interrupt — consume the stale signal
|
|
2945
3425
|
}
|
|
3426
|
+
const isActive = isClaudeTurnSlotReserved(session.state.state);
|
|
2946
3427
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
|
|
2947
3428
|
const sdkTurnActive = isActive && !session.pendingFinalDelivery;
|
|
2948
3429
|
session.controlInterruptPending = sdkTurnActive;
|