@canonmsg/claude-code-plugin 0.28.0 → 0.29.0

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