@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/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,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 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);
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
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply delivery exhausted after bounded retries`);
1355
- session.pendingFinalDelivery = null;
1356
- resetTurnToIdle();
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 === 'thinking'
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
- if (!session.currentTurnId) {
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
- async function startCanonInput(input) {
1505
- await markQueuedMessageAccepted(input.sourceMessageId, input.markAccepted);
1506
- await prepareArtifactsForInput(input);
1507
- session.state.state = 'running';
1508
- sendInput(input);
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 (session.closed
1512
- || session.controlInterruptPending
1513
- || session.state.state === 'running'
1514
- || session.pendingInputs.length === 0)
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
- void startCanonInput(next);
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
- 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);
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 (session.state.state === 'running' && intent === 'interrupt') {
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
- writeTurn();
1565
- void startCanonInput(input);
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
- 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(() => { });
2020
+ applyTrailCommands(commands);
1613
2021
  startVisibleWorkSignal();
1614
2022
  writeTurn();
1615
2023
  }
1616
- else if (event?.type === 'content_block_stop' && session.toolInProgress) {
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
- session.pendingFinalText = null;
1648
- session.toolInProgress = false;
1649
- session.turnState = 'thinking';
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
- session.state.state = finalDelivered ? 'idle' : session.state.state;
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 isActive = session.state.state === 'running' || session.state.state === 'requires_action';
2943
- if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
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;