@canonmsg/claude-code-plugin 0.28.1 → 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/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
- const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
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
- const MAX_FINAL_DELIVERY_RETRIES = 1;
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
- function getFinalTurnTrail() {
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
- return streamingOutput.getFinalTrail().map((block) => ({ ...block, turnId }));
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
- session.pendingFinalDelivery = {
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,100 @@ 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 sendMessageWithRetry(client, conversationId, finalText, {
1464
+ const { messageIds } = await sendMessageWithRetryChunked(client, conversationId, finalText, {
1284
1465
  messageId: finalMessageId,
1285
- ...(session.activeSelfContextId
1286
- ? { selfContextId: session.activeSelfContextId }
1287
- : {}),
1288
- metadata: buildClaudeFinalTurnMetadata({
1289
- turnId: session.currentTurnId,
1290
- turnKey: deliverTurn.turnKey,
1291
- sourceMessageId: deliverTurn.sourceMessageId,
1292
- deliveryIntent: session.lastAcceptedIntent,
1293
- turnTrail: getFinalTurnTrail(),
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
- markPendingFinalDelivery(finalText, deliverTurn, suppressAutoReply);
1308
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
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);
1309
1531
  return false;
1310
1532
  }
1311
1533
  }
@@ -1351,9 +1573,29 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1351
1573
  if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
1352
1574
  return;
1353
1575
  if (pending.retryCount >= MAX_FINAL_DELIVERY_RETRIES) {
1354
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply delivery exhausted after bounded retries`);
1355
- session.pendingFinalDelivery = null;
1356
- resetTurnToIdle();
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
+ });
1357
1599
  return;
1358
1600
  }
1359
1601
  pending.retryCount += 1;
@@ -1443,10 +1685,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1443
1685
  writeState();
1444
1686
  }
1445
1687
  function writeTurn() {
1446
- const isOpenTurn = session.turnState === 'thinking'
1447
- || session.turnState === 'streaming'
1448
- || session.turnState === 'tool'
1449
- || session.turnState === 'waiting_input';
1688
+ const isOpenTurn = isOpenClaudeTurnState(session.turnState);
1450
1689
  if (isOpenTurn) {
1451
1690
  session.currentTurnUpdatedAt = Date.now();
1452
1691
  }
@@ -1489,10 +1728,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1489
1728
  async function prepareArtifactsForInput(input) {
1490
1729
  if (input.kind !== 'canon')
1491
1730
  return;
1492
- if (!session.currentTurnId) {
1493
- session.currentTurnId = randomUUID();
1494
- session.currentTurnOpenedAt = Date.now();
1495
- }
1731
+ // The turn id is minted by openTurn(), which runs before this.
1496
1732
  if (input.artifactRoutingMode === 'workspace-generated') {
1497
1733
  input.artifactBaseline = await captureTurnArtifactSnapshot({ cwd: session.cwd }).catch((error) => {
1498
1734
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact snapshot failed:`, error instanceof Error ? error.message : error);
@@ -1501,24 +1737,149 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1501
1737
  return;
1502
1738
  }
1503
1739
  }
1504
- async function startCanonInput(input) {
1505
- await markQueuedMessageAccepted(input.sourceMessageId, input.markAccepted);
1506
- await prepareArtifactsForInput(input);
1507
- session.state.state = 'running';
1508
- sendInput(input);
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
+ });
1509
1858
  }
1510
1859
  function drainPendingInput() {
1511
- if (session.closed
1512
- || session.controlInterruptPending
1513
- || session.state.state === 'running'
1514
- || session.pendingInputs.length === 0)
1860
+ if (!canDrainClaudeQueuedInput({
1861
+ closed: session.closed,
1862
+ controlInterruptPending: session.controlInterruptPending,
1863
+ sessionState: session.state.state,
1864
+ queueDepth: session.pendingInputs.length,
1865
+ }))
1515
1866
  return;
1516
1867
  const next = session.pendingInputs.shift();
1517
1868
  if (!next)
1518
1869
  return;
1519
1870
  session.lastAcceptedIntent = next.intent;
1520
- writeTurn();
1521
- void startCanonInput(next);
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;
1522
1883
  }
1523
1884
  enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => {
1524
1885
  session.lastActivity = Date.now();
@@ -1532,17 +1893,28 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1532
1893
  requestingUserId,
1533
1894
  artifactRoutingMode,
1534
1895
  });
1535
- if (session.pendingFinalDelivery) {
1536
- session.pendingInputs.push(input);
1537
- writeTurn();
1538
- return;
1539
- }
1540
- if (session.state.state === 'running' && intent !== 'interrupt') {
1541
- session.pendingInputs.push(input);
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
+ }
1542
1914
  writeTurn();
1543
1915
  return;
1544
1916
  }
1545
- if (session.state.state === 'running' && intent === 'interrupt') {
1917
+ if (decision === 'interrupt') {
1546
1918
  session.pendingInputs.unshift(input);
1547
1919
  writeTurn();
1548
1920
  const interruptedInput = session.activeInput;
@@ -1561,10 +1933,27 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1561
1933
  return;
1562
1934
  }
1563
1935
  session.lastAcceptedIntent = intent;
1564
- writeTurn();
1565
- void startCanonInput(input);
1936
+ // Same as in drainPendingInput: openTurn() does the publish, as 'thinking'.
1937
+ dispatchCanonInput(input);
1566
1938
  };
1567
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
+ }
1568
1957
  // ── Process SDK output messages ──
1569
1958
  const processMessages = async () => {
1570
1959
  try {
@@ -1584,6 +1973,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1584
1973
  // the final. Gate on the owning turn instead of on arrival order.
1585
1974
  if (!claudeInputOwnsTurnSlot(session.activeInput))
1586
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 ?? []);
1990
+ }
1587
1991
  const textBlocks = (msg.message?.content ?? [])
1588
1992
  .filter((b) => b.type === 'text')
1589
1993
  .map((b) => b.text);
@@ -1599,23 +2003,36 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1599
2003
  stopVisibleWorkSignal();
1600
2004
  break;
1601
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;
1602
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
+ });
1603
2024
  session.toolInProgress = true;
1604
2025
  session.turnState = 'tool';
1605
- streamingOutput.addBlock({
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(() => { });
2026
+ applyTrailCommands(commands);
1613
2027
  startVisibleWorkSignal();
1614
2028
  writeTurn();
1615
2029
  }
1616
- else if (event?.type === 'content_block_stop' && session.toolInProgress) {
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.
1617
2035
  session.toolInProgress = false;
1618
- streamingOutput.completeBlock(`tool:${session.currentTurnId ?? conversationId}`).catch(() => { });
1619
2036
  if (session.state.state === 'running') {
1620
2037
  session.turnState = 'thinking';
1621
2038
  startVisibleWorkSignal();
@@ -1632,21 +2049,70 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1632
2049
  }
1633
2050
  break;
1634
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
+ }
1635
2080
  case 'system': {
1636
2081
  const subtype = msg.subtype;
1637
2082
  if (subtype === 'session_state_changed') {
1638
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
+ }
1639
2098
  session.state.state = state;
1640
2099
  if (state === 'running') {
1641
2100
  clearIdleResetTimer();
1642
- if (!session.currentTurnId) {
1643
- session.currentTurnId = randomUUID();
1644
- session.currentTurnOpenedAt = Date.now();
1645
- }
1646
2101
  session.state.contextUsage = undefined;
1647
- session.pendingFinalText = null;
1648
- session.toolInProgress = false;
1649
- session.turnState = 'thinking';
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
+ }
1650
2116
  startVisibleWorkSignal();
1651
2117
  }
1652
2118
  else if (state === 'idle') {
@@ -1731,7 +2197,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1731
2197
  totalTokens: usage.totalTokens,
1732
2198
  maxTokens: usage.maxTokens,
1733
2199
  };
1734
- session.state.state = finalDelivered ? 'idle' : session.state.state;
2200
+ if (finalDelivered)
2201
+ releaseTurnSlotFor(completedInput);
1735
2202
  writeState();
1736
2203
  }
1737
2204
  catch {
@@ -2318,8 +2785,12 @@ export async function main() {
2318
2785
  }
2319
2786
  async function resetRuntimeSession(session) {
2320
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.
2321
2792
  const sdkTurnActive = (session.state.state === 'running'
2322
- || session.state.state === 'requires_action') && !session.pendingFinalDelivery;
2793
+ || session.state.state === 'requires_action') && !session.pendingFinalDelivery && !session.dispatchingInput;
2323
2794
  session.controlInterruptPending = sdkTurnActive;
2324
2795
  const interruptResult = await confirmClaudeInterrupt({
2325
2796
  active: sdkTurnActive,
@@ -2939,10 +3410,25 @@ export async function main() {
2939
3410
  }
2940
3411
  return;
2941
3412
  }
2942
- const isActive = session.state.state === 'running' || session.state.state === 'requires_action';
2943
- if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
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') {
2944
3429
  return; // Nothing to interrupt — consume the stale signal
2945
3430
  }
3431
+ const isActive = isClaudeTurnSlotReserved(session.state.state);
2946
3432
  console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
2947
3433
  const sdkTurnActive = isActive && !session.pendingFinalDelivery;
2948
3434
  session.controlInterruptPending = sdkTurnActive;