@oar-sh/oar 0.9.4 → 0.9.6

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.
Files changed (71) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +199 -0
  4. package/README.md +35 -6
  5. package/package.json +2 -1
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  7. package/server/claude-worker/claude-session-process.mjs +411 -19
  8. package/server/claude-worker/claude-session-worker.mjs +5 -0
  9. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  10. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  11. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  12. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  13. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  14. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  17. package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
  18. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  19. package/server/cursor-worker/cursor-session-worker.mjs +5 -0
  20. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  21. package/server/db-schema.mjs +7 -0
  22. package/server/grok-worker/acp-client.mjs +68 -12
  23. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  24. package/server/grok-worker/grok-session-worker.mjs +5 -0
  25. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  26. package/server/public/app/api-client.js +17 -2
  27. package/server/public/app/bootstrap.js +3 -0
  28. package/server/public/app/composer-control-state.mjs +4 -3
  29. package/server/public/app/conversation-view.js +97 -4
  30. package/server/public/app/journal-view.js +6 -0
  31. package/server/public/app/markdown-strikethrough.mjs +31 -0
  32. package/server/public/app/relay-board-inline.mjs +70 -0
  33. package/server/public/app/relay-board-view.js +157 -61
  34. package/server/public/app/relay-error-ctas.mjs +12 -3
  35. package/server/public/app/socket-handlers.js +9 -0
  36. package/server/public/app/steered-turn-merge.mjs +10 -1
  37. package/server/public/app/store.js +4 -1
  38. package/server/public/app/subagent-fold.mjs +61 -0
  39. package/server/public/app/usage-limit-ui.js +225 -0
  40. package/server/public/index.html +97 -4
  41. package/server/repositories/message-repository.mjs +10 -1
  42. package/server/repositories/question-repository.mjs +1 -0
  43. package/server/repositories/remote-relay-repository.mjs +30 -0
  44. package/server/routes/messages-routes.mjs +93 -17
  45. package/server/routes/relay-board-routes.mjs +56 -2
  46. package/server/routes/sessions-routes.mjs +40 -2
  47. package/server/server-runtime.mjs +33 -2
  48. package/server/services/preview-proxy-server.mjs +64 -36
  49. package/server/services/process-list-service.mjs +122 -0
  50. package/server/services/relay-console-log.mjs +199 -0
  51. package/server/services/remote-relay-caller-context.mjs +45 -20
  52. package/server/services/remote-relay-dispatcher.mjs +328 -21
  53. package/server/services/remote-relay-inbound.mjs +35 -0
  54. package/server/services/session-worker-launch-service.mjs +17 -2
  55. package/server/services/session-worker-process-service.mjs +85 -32
  56. package/server/services/session-worker-registry-service.mjs +1 -1
  57. package/server/services/session-worker-supervisor-service.mjs +33 -1
  58. package/server/services/usage-limit-pause-service.mjs +441 -0
  59. package/shared/ask-user-bridge.mjs +14 -39
  60. package/shared/claude-usage-limit.mjs +257 -0
  61. package/shared/control-poller.mjs +69 -20
  62. package/shared/failure-note-text.mjs +38 -0
  63. package/shared/question-wait.mjs +108 -0
  64. package/shared/remote-relay-contract.mjs +33 -2
  65. package/shared/steer-settle-failure.mjs +22 -0
  66. package/shared/steer-settle-markers.mjs +24 -0
  67. package/shared/worker-runtime/process-tree.mjs +483 -0
  68. package/shared/worker-runtime/relay-errors.mjs +31 -0
  69. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  70. package/shared/worker-runtime/turn-liveness.mjs +249 -0
  71. package/shared/worker-runtime/worker-log-file.mjs +97 -0
@@ -37,13 +37,20 @@ import {
37
37
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
38
38
  import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
39
39
  import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
40
- import { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT } from '../../shared/steer-settle-markers.mjs';
40
+ import { createTurnLiveness, resolveTurnStallWindows } from '../../shared/worker-runtime/turn-liveness.mjs';
41
+ import {
42
+ ANSWERED_ELSEWHERE_KIND,
43
+ DELIVERY_ANSWERED_ELSEWHERE_TEXT,
44
+ STEER_FOLDED_TEXT,
45
+ STEER_STOPPED_TEXT,
46
+ } from '../../shared/steer-settle-markers.mjs';
41
47
  import { stripSteerNote, withSteerNoteContent } from '../../shared/steer-note.mjs';
42
48
  import {
43
49
  stripSlashCommandGuard,
44
50
  stripSlashCommandGuardContent,
45
51
  withSlashCommandGuardContent,
46
52
  } from '../../shared/slash-command-guard.mjs';
53
+ import { createUsageLimitTracker } from '../../shared/claude-usage-limit.mjs';
47
54
 
48
55
  /**
49
56
  * Which system-prompt append a relay mode gets (claude-sdk-adapter's
@@ -446,6 +453,10 @@ export function createClaudeSessionRunner({
446
453
  // next delivery.
447
454
  getThinking = () => ({ enabled: null, display: '' }),
448
455
  lifecyclePollMs = 5_000,
456
+ // How long a RUNNING turn may go without a message from the CLI before
457
+ // that is written to the log. Nothing is failed over it: see
458
+ // `noteQuietTurn`. `{ model, tool }` in ms; 0 for either turns it off.
459
+ quietTurnWindowsMs = undefined,
449
460
  // A settled task's continuation normally begins within ~1s; when nothing
450
461
  // arrives inside this window the notification was silent (skip_transcript)
451
462
  // and must stop pinning the process.
@@ -464,6 +475,17 @@ export function createClaudeSessionRunner({
464
475
  // takes to distinguish a fold from a queued run. Far below the watchdog so a
465
476
  // folded row settles as answered, not failed with a "resend" error.
466
477
  steeredFoldGraceMs = 2_000,
478
+ // How long a NON-steered entry may sit unattached after a continuation that
479
+ // opened over it has published its answer, with the stream quiet and no
480
+ // turn starting, before it is settled as answered by that continuation. The
481
+ // CLI starts the turn of a message queued behind another one when that
482
+ // turn ends, and every turn opens with an init, which takes the entry out
483
+ // of this settle for good. So the window only has to outlast the gap up to
484
+ // that init (input processing, hooks); it is sized well above it because a
485
+ // settle that comes too early costs the user the real answer's place.
486
+ // Without the settle the row held every later message out of the worker
487
+ // until the watchdog failed it minutes later. 0 = disabled.
488
+ strandedSettleMs = 45_000,
467
489
  // Backstop for the compaction hold. A compaction is announced ONCE by
468
490
  // `status: 'compacting'` (the CLI's periodic re-emit is gated on the
469
491
  // remote-control client's activity callback, which the plain SDK `query()`
@@ -530,6 +552,9 @@ export function createClaudeSessionRunner({
530
552
  // fixed per process, and the first delivery can beat the startup GET.
531
553
  const remoteRelayGate = remoteRelayToolGate || createRemoteRelayToolGate({ api });
532
554
  void remoteRelayGate.refresh();
555
+ // The subscription's usage limit as the CLI reports it. Runner-level: the
556
+ // limit is the account's, not the CLI process's.
557
+ const usageLimit = createUsageLimitTracker();
533
558
  const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
534
559
  let claudeNativeSessionId = '';
535
560
  let proc = null;
@@ -793,6 +818,8 @@ export function createClaudeSessionRunner({
793
818
  function attachDeliveredContext(index = 0, { provisional = false } = {}) {
794
819
  const [entry] = proc.pendingDelivered.splice(index, 1);
795
820
  entry.ctx.deliveredEntry = entry;
821
+ // It has a turn now; an adoption that is unwound puts it back unmarked.
822
+ entry.overtakenBy = null;
796
823
  // A provisional (post-compaction) attach may still be unwound back onto
797
824
  // the queue; its un-mark waits for the adoption to commit.
798
825
  if (!provisional) unmarkConsumedOnOwnTurn(entry);
@@ -869,7 +896,89 @@ export function createClaudeSessionRunner({
869
896
  function handBackAdoptedContext(ctx) {
870
897
  restoreAdoptedContext(ctx);
871
898
  dbg('post-compaction adoption handed back to a task continuation', ctx.message?.id || '(no id)');
872
- return openContinuationContext();
899
+ return openContinuationContext({ evidence: 'a task notification replayed into the adopted turn' });
900
+ }
901
+
902
+ /**
903
+ * Mark the next turn as one the CLI opened by itself. `corroborated` says
904
+ * whether the signal proves that on its own (a turn opener's replay, the
905
+ * Stop guard standing down) or only suggests it (a bare init, which the CLI
906
+ * also emits for reasons of its own). Sticky until the boundary is spent: a
907
+ * weaker signal behind a proven one does not take the proof back.
908
+ *
909
+ * Every marking starts with nothing superseding it, a re-marking of an
910
+ * armed boundary included. A bare init marks only with nothing queued, so
911
+ * it precedes any later push; and a boundary can stay armed with the flag
912
+ * set, when the message whose init set it was failed by the watchdog
913
+ * without a turn ever opening. Left standing, that flag made the next turn
914
+ * the CLI opened by itself look superseded by an init it never saw.
915
+ */
916
+ function markSelfOpenedBoundary(reason, { corroborated = false } = {}) {
917
+ if (proc.lastBoundary === 'self-opened' && proc.boundaryCorroborated) return;
918
+ proc.lastBoundary = 'self-opened';
919
+ proc.boundarySuperseded = false;
920
+ proc.boundaryCorroborated = corroborated;
921
+ proc.boundaryReason = reason;
922
+ }
923
+
924
+ /**
925
+ * What says the CLI really opened the turn the boundary announces, or ''
926
+ * when nothing does. Beyond the signal itself:
927
+ * - a task settled and no continuation has opened since. Counted, not
928
+ * timed, and not reset by a turn ending: a task that settles early in a
929
+ * long turn has its continuation open after that turn, by which time
930
+ * both the notification grace and `settleSeqAtTurnEnd` have moved on.
931
+ * Over-long by design: a settle whose notification was silent or folded
932
+ * into another turn stays evidence until the next continuation opens,
933
+ * which only keeps a boundary the way it was always kept;
934
+ * - a live task, which can report back without settling;
935
+ * - order: the init that armed the boundary came before the waiting
936
+ * message was pushed (an init arms it only with nothing queued), so that
937
+ * turn is not the message's own, and no later init has started another
938
+ * turn in its place (see `boundarySuperseded`).
939
+ */
940
+ function selfOpenedEvidence() {
941
+ if (proc.boundaryCorroborated) return proc.boundaryReason;
942
+ if (proc.taskSettleSeq !== proc.settleSeqAtContinuationOpen) {
943
+ return 'a background task settled and no continuation has opened since';
944
+ }
945
+ if (proc.liveTasks.size > 0) return 'a live background task can report back';
946
+ if (!proc.boundarySuperseded) return 'its init came before the message was pushed and no turn has started since';
947
+ return '';
948
+ }
949
+
950
+ /**
951
+ * A self-opened boundary diverts the next turn's answer to a continuation
952
+ * row. One armed by a bare init stays armed until a turn opens, however
953
+ * long that takes, and the CLI inits for reasons of its own: read as
954
+ * self-opened, a settings re-init published the user's answer on a
955
+ * continuation and left the delivered row unattached, holding every later
956
+ * message out until the watchdog failed it (see the init handler).
957
+ *
958
+ * Every turn opens with an init, so what tells a stale boundary from a live
959
+ * one is what follows the push. Traffic with no init in between belongs to
960
+ * the turn the armed init opened: a continuation, as before. A SECOND init,
961
+ * after the push, starts a newer turn; the armed one never produced
962
+ * anything, and unless a task corroborates it the boundary is dropped here
963
+ * and the turn attaches to the waiting message, as if it had never been
964
+ * marked. Steered entries do not count: they settle by the fold reaper. A
965
+ * boundary marked by a replay is never dropped; if that one is false, the
966
+ * settle in reapOvertakenDeliveredEntries recovers.
967
+ */
968
+ function disownUnprovenBoundary() {
969
+ if (proc.lastBoundary !== 'self-opened') return;
970
+ const waiting = proc.pendingDelivered.find((entry) => !entry.steered);
971
+ if (!waiting || selfOpenedEvidence()) return;
972
+ dbg(
973
+ 'self-opened boundary has no corroboration; the turn goes to the waiting message',
974
+ shortMessageId(waiting.ctx.message),
975
+ `boundary=${proc.boundaryReason}`,
976
+ );
977
+ proc.lastBoundary = null;
978
+ }
979
+
980
+ function shortMessageId(message) {
981
+ return String(message?.id || '').slice(0, 8) || '(no id)';
873
982
  }
874
983
 
875
984
  /**
@@ -879,7 +988,7 @@ export function createClaudeSessionRunner({
879
988
  * discards the turn's relay output (it still lands in the native
880
989
  * transcript) rather than failing the process.
881
990
  */
882
- function openContinuationContext() {
991
+ function openContinuationContext({ evidence = '' } = {}) {
883
992
  const ctx = createContext('continuation', {
884
993
  id: null,
885
994
  conversationId: sdkSessionId,
@@ -887,6 +996,26 @@ export function createClaudeSessionRunner({
887
996
  model: '',
888
997
  });
889
998
  ctx.registered = false;
999
+ // Delivered messages this turn opens over. Remembered on the entry: if
1000
+ // this turn turns out to have taken their prompt, nothing will ever open
1001
+ // for them (see reapOvertakenDeliveredEntries). Read before the
1002
+ // activation below spends the boundary.
1003
+ const overtaken = proc.pendingDelivered.filter((entry) => !entry.steered);
1004
+ if (overtaken.length) {
1005
+ const selfOpened = proc.lastBoundary === 'self-opened';
1006
+ dbg(
1007
+ 'continuation opens while a delivered message waits unattached',
1008
+ overtaken.map((entry) => shortMessageId(entry.ctx.message)).join(','),
1009
+ `boundary=${selfOpened ? proc.boundaryReason : 'none'}`,
1010
+ `reason=${evidence || (selfOpened && selfOpenedEvidence()) || 'none'}`,
1011
+ );
1012
+ for (const entry of overtaken) {
1013
+ entry.overtakenBy = ctx;
1014
+ entry.overtakenSince = 0;
1015
+ }
1016
+ }
1017
+ // Whatever settled up to here has its continuation now.
1018
+ proc.settleSeqAtContinuationOpen = proc.taskSettleSeq;
890
1019
  activateContext(ctx);
891
1020
  (async () => {
892
1021
  // One idempotency key for the whole loop: a retry whose predecessor was
@@ -1149,11 +1278,21 @@ export function createClaudeSessionRunner({
1149
1278
  // A turn the CLI opened on its own (task-notification replay). The
1150
1279
  // context opens lazily on its first real traffic so a bookkeeping
1151
1280
  // replay that ends in a phantom result never creates a relay turn.
1152
- proc.lastBoundary = 'self-opened';
1281
+ // A turn opener that matched no delivered message proves the turn is
1282
+ // the CLI's own, tagged or not: the adoption rules above rest on that.
1283
+ const originKind = String(sdkMessage?.origin?.kind || '').trim();
1284
+ markSelfOpenedBoundary(
1285
+ isTaskNotificationReplay(sdkMessage)
1286
+ ? 'a task notification replay'
1287
+ : `an unmatched user replay (origin ${originKind || 'none'})`,
1288
+ { corroborated: true },
1289
+ );
1290
+ turnStartedForWaitingDeliveries();
1153
1291
  return null;
1154
1292
  }
1155
1293
 
1156
1294
  if (type === 'assistant' || type === 'stream_event' || type === 'user') {
1295
+ disownUnprovenBoundary();
1157
1296
  if (proc.lastBoundary !== 'self-opened') {
1158
1297
  // A background subagent's frames arrive at top level carrying
1159
1298
  // `parent_tool_use_id` (same marker the adoption commit checks): they
@@ -1177,6 +1316,7 @@ export function createClaudeSessionRunner({
1177
1316
  proc.lastBoundary = null;
1178
1317
  return null;
1179
1318
  }
1319
+ disownUnprovenBoundary();
1180
1320
  if (proc.lastBoundary !== 'self-opened' && proc.pendingDelivered.length && !sdkMessage?.parent_tool_use_id) {
1181
1321
  return attachDeliveredContext();
1182
1322
  }
@@ -1971,11 +2111,21 @@ export function createClaudeSessionRunner({
1971
2111
  if (type !== 'system') return;
1972
2112
  const subtype = String(sdkMessage.subtype || '');
1973
2113
  if (subtype === 'init') {
1974
- // The FIRST init of a process is the spawn's, and a spawn is always
1975
- // driven by a delivered message; a LATER one is the CLI opening a turn
1976
- // by itself.
2114
+ // The CLI emits an init at the start of EVERY turn, after it has taken
2115
+ // the turn's input and before the API request (read in the CLI, 2.1.282):
2116
+ // a delivered message's own turn opens init → traffic, with no user
2117
+ // replay, exactly like a continuation the CLI opens by itself. The
2118
+ // FIRST init of a process is the spawn's, and a spawn is always driven
2119
+ // by a delivered message.
1977
2120
  const firstInit = !proc.sawInit;
1978
2121
  proc.sawInit = true;
2122
+ // A turn is starting while delivered messages wait, and it may be
2123
+ // theirs: they leave the overtaken settle for good. Whether a context is
2124
+ // still active does not matter. A continuation whose result arrived
2125
+ // while its row was still registering is active when the next turn's
2126
+ // init lands, and skipping that init settled the message with a pointer
2127
+ // and dropped its real answer.
2128
+ if (!firstInit && proc.pendingDelivered.length) turnStartedForWaitingDeliveries();
1979
2129
  persistNativeSessionId(sdkSessionId, sdkMessage.session_id).catch(() => {});
1980
2130
  // Tracked on the process too (not only through the persist round-trip):
1981
2131
  // the workflow poller needs the native id to find the session dir even
@@ -2010,17 +2160,20 @@ export function createClaudeSessionRunner({
2010
2160
  // same way.
2011
2161
  if (settingsReinitsOwed > 0) settingsReinitsOwed -= 1;
2012
2162
  else if (!settingsWindowOpen || proc.taskSettleSeq !== proc.settleSeqAtTurnEnd) {
2013
- proc.lastBoundary = 'self-opened';
2163
+ markSelfOpenedBoundary('a bare init with nothing queued');
2014
2164
  }
2015
2165
  } else if (!proc.activeCtx && !firstInit) {
2016
- // A LATER init, with a delivered row waiting, is the CLI opening a turn
2017
- // of its own — the live-verified continuation shape is a bare init with
2018
- // no user replay at all. A delivered turn cannot produce one: the
2019
- // process is already running, and only a spawn inits (which is
2020
- // `firstInit`, and always has its row queued before the stream is
2021
- // read). A compaction inside such a turn would otherwise see no
2022
- // turn-level boundary and adopt the queued row — and in this shape
2023
- // nothing later replays to correct it.
2166
+ // A LATER init, with a delivered row waiting: a turn is starting, and
2167
+ // the init cannot say whose. It may be the CLI opening a turn of its
2168
+ // own (the live-verified continuation shape is a bare init with no
2169
+ // user replay at all) or the waiting message's own turn, which opens
2170
+ // with an init just the same. For a compaction inside that turn the
2171
+ // flag errs toward the continuation: adopting the queued row for a
2172
+ // turn that was the CLI's own closes it with another turn's answer,
2173
+ // and in this shape nothing later replays to correct it. When the
2174
+ // turn was the message's own, its answer publishes on a continuation
2175
+ // row instead and the row is settled with a pointer to it (see
2176
+ // reapOvertakenDeliveredEntries).
2024
2177
  //
2025
2178
  // Structural, not a clock. Gating this on "a task settled recently"
2026
2179
  // fails twice over: the grace (60 s) is shorter than a compaction
@@ -2036,6 +2189,9 @@ export function createClaudeSessionRunner({
2036
2189
  // continuation). This suppresses adoption and nothing else; if the turn
2037
2190
  // really is the delivered message's, it attaches exactly as before.
2038
2191
  proc.continuationInitPending = true;
2192
+ // A boundary an earlier init armed announced a turn that never
2193
+ // produced anything: this init starts a newer one in its place.
2194
+ if (proc.lastBoundary === 'self-opened') proc.boundarySuperseded = true;
2039
2195
  }
2040
2196
  return;
2041
2197
  }
@@ -2328,6 +2484,90 @@ export function createClaudeSessionRunner({
2328
2484
  return 'swallow';
2329
2485
  }
2330
2486
 
2487
+ // ---------------------------------------------------------------------------
2488
+ // A running turn that went quiet
2489
+ //
2490
+ // The other workers fail a turn whose runtime has been silent for too long
2491
+ // (shared/worker-runtime/turn-liveness.mjs). This one only WRITES IT DOWN.
2492
+ // What the CLI sends is known (SDK 0.3.283, measured 2026-09-28): a
2493
+ // `tool_progress` heartbeat every 30 s while a tool runs in silence, for the
2494
+ // Agent call as a whole when the tool runs inside a subagent; a
2495
+ // `thinking_tokens` or stream frame about every second while the model
2496
+ // works. What is not known is how long its silent stretches get in real
2497
+ // sessions: a compaction (133 s seen), the backoff of an API retry, a
2498
+ // request it is waiting on. Failing a turn here also means killing the CLI
2499
+ // and its background tasks. So the windows are generous, the known silent
2500
+ // stretches are holds, and a turn that outlasts them is logged with what
2501
+ // the CLI was doing — the measurements a failing watchdog would need.
2502
+
2503
+ const quietWindows = (() => {
2504
+ const windows = resolveTurnStallWindows({
2505
+ // The CLI gives up on a silent model stream by itself after 300 s and
2506
+ // says so (`api_retry`); a window of the same length would race it.
2507
+ modelMs: quietTurnWindowsMs?.model ?? process.env.OAR_TURN_STALL_MODEL_MS ?? 600_000,
2508
+ toolMs: quietTurnWindowsMs?.tool,
2509
+ });
2510
+ return { idle: 0, model: windows.model, tool: windows.tool };
2511
+ })();
2512
+
2513
+ function observeQuietTurn(sdkMessage) {
2514
+ const liveness = proc?.liveness;
2515
+ if (!liveness) return;
2516
+ const type = String(sdkMessage?.type || '');
2517
+ const agent = String(sdkMessage?.parent_tool_use_id || '').trim() || 'root';
2518
+ if (proc.quietLoggedAt) {
2519
+ dbg(`the CLI spoke again after ${Math.round(liveness.quietMs() / 1000)}s of silence`, type);
2520
+ proc.quietLoggedAt = 0;
2521
+ }
2522
+ liveness.traffic(type === 'system' ? `system/${sdkMessage?.subtype || ''}` : type);
2523
+ const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
2524
+ if (type === 'assistant') {
2525
+ for (const block of blocks) {
2526
+ if (block?.type === 'tool_use' && block.id) liveness.begin('tool', `${agent}:${block.id}`, block.name);
2527
+ }
2528
+ } else if (type === 'user') {
2529
+ for (const block of blocks) {
2530
+ if (block?.type !== 'tool_result' || !block.tool_use_id) continue;
2531
+ liveness.end('tool', `${agent}:${block.tool_use_id}`);
2532
+ // The result of an Agent call: whatever that agent had open ended with it.
2533
+ liveness.endAgent(String(block.tool_use_id));
2534
+ }
2535
+ } else if (type === 'stream_event') {
2536
+ const event = String(sdkMessage?.event?.type || '');
2537
+ if (event === 'message_start') liveness.begin('model', agent);
2538
+ else if (event === 'message_stop') liveness.end('model', agent);
2539
+ } else if (type === 'result') {
2540
+ liveness.reset();
2541
+ }
2542
+ }
2543
+
2544
+ /**
2545
+ * Log a running turn the CLI has said nothing in for longer than its phase
2546
+ * allows. Once per silence, and never while the silence has a known reason.
2547
+ */
2548
+ function noteQuietTurn() {
2549
+ const liveness = proc?.liveness;
2550
+ const ctx = proc?.activeCtx;
2551
+ if (!liveness || !ctx || ctx.finalized || proc.closing || proc.quietLoggedAt) return;
2552
+ // A human is deciding, a compaction is running, the relay is slow to take
2553
+ // a publish (the stream is not read meanwhile), or the turn is being
2554
+ // stopped: silence with a reason.
2555
+ if (proc.pendingControlRequests > 0 || isCompacting() || proc.dispatching > 0 || ctx.interrupted) {
2556
+ liveness.traffic('held');
2557
+ return;
2558
+ }
2559
+ // Inside a turn the CLI is waiting for the model unless a tool is open.
2560
+ const phase = liveness.phase() === 'tool' ? 'tool' : 'model';
2561
+ const limitMs = quietWindows[phase];
2562
+ if (!(limitMs > 0) || liveness.quietMs() < limitMs) return;
2563
+ proc.quietLoggedAt = Date.now();
2564
+ dbg(
2565
+ `running turn quiet for ${Math.round(liveness.quietMs() / 1000)}s; left running (the quiet-turn watchdog only observes)`,
2566
+ ctx.message?.id || '(continuation)',
2567
+ liveness.describe(),
2568
+ );
2569
+ }
2570
+
2331
2571
  async function onSdkMessage(processRef, sdkMessage) {
2332
2572
  // A superseded process (push-race respawn, mode-recycle timeout) may still
2333
2573
  // be draining its stream; its late messages must not mutate the state of
@@ -2335,13 +2575,30 @@ export function createClaudeSessionRunner({
2335
2575
  // cleanupProcess when the old stream ends.
2336
2576
  if (!proc || proc !== processRef) return;
2337
2577
  proc.lastEventAt = Date.now();
2578
+ observeQuietTurn(sdkMessage);
2579
+ // Counted for as long as this message is being handled: the stream is
2580
+ // not read meanwhile, so the silence is this worker's, not the CLI's.
2581
+ processRef.dispatching += 1;
2582
+ try {
2583
+ await handleSdkMessage(sdkMessage);
2584
+ } finally {
2585
+ processRef.dispatching -= 1;
2586
+ if (proc === processRef) proc.liveness.traffic('handled');
2587
+ }
2588
+ }
2589
+
2590
+ async function handleSdkMessage(sdkMessage) {
2591
+ const usageLimitReport = usageLimit.observe(sdkMessage);
2592
+ if (usageLimitReport) publisher.publishUsageLimitReport({ conversationId: sdkSessionId, sdkSessionId, report: usageLimitReport });
2338
2593
  const suppressed = maybeSuppressStopFollowUp(sdkMessage);
2339
2594
  if (suppressed === 'swallow') return;
2340
2595
  observeProcessLevel(sdkMessage);
2341
2596
  if (suppressed === 'observe-only') return;
2342
2597
  // Its traffic opens a continuation of its own instead of attaching to the
2343
2598
  // stopped steer waiting in pendingDelivered (which still settles stopped).
2344
- if (suppressed === 'continuation') proc.lastBoundary = 'self-opened';
2599
+ if (suppressed === 'continuation') {
2600
+ markSelfOpenedBoundary('the stop guard stood down for a task continuation', { corroborated: true });
2601
+ }
2345
2602
  const ctx = resolveContext(sdkMessage);
2346
2603
  if (proc.handoffSettling) {
2347
2604
  // An absorbed-steering handoff just settled the previous context;
@@ -2353,6 +2610,8 @@ export function createClaudeSessionRunner({
2353
2610
  if (!ctx) return;
2354
2611
  const actions = ctx.normalizer.normalize(sdkMessage);
2355
2612
  for (const action of actions) {
2613
+ // Decided here, while the reports the turn ran under are the latest.
2614
+ if (action.channel === 'result') action.payload.usageLimit = usageLimit.classifyResult(sdkMessage);
2356
2615
  await dispatchToContext(ctx, action);
2357
2616
  }
2358
2617
  }
@@ -2530,6 +2789,112 @@ export function createClaudeSessionRunner({
2530
2789
  }
2531
2790
  }
2532
2791
 
2792
+ /**
2793
+ * A turn is starting while delivered messages wait. Whatever opened over
2794
+ * them before, they are no longer known to be left behind: this turn may be
2795
+ * theirs, and its first traffic can take longer than the settle window.
2796
+ */
2797
+ function turnStartedForWaitingDeliveries() {
2798
+ for (const entry of proc.pendingDelivered) entry.overtakenBy = null;
2799
+ }
2800
+
2801
+ /** The continuation ran to a clean end and its answer is on the relay. */
2802
+ function publishedItsAnswer(ctx) {
2803
+ return Boolean(ctx)
2804
+ && ctx.finalized
2805
+ && ctx.registered
2806
+ && !ctx.discarded
2807
+ && !ctx.interrupted
2808
+ && Boolean(ctx.message?.id)
2809
+ && Boolean(ctx.state.result)
2810
+ && !ctx.state.result.isError;
2811
+ }
2812
+
2813
+ /**
2814
+ * Settle delivered messages whose prompt a continuation's turn took. A
2815
+ * continuation opened over a waiting NON-steered entry either ran ahead of
2816
+ * the entry's own turn, which then follows at once, or WAS that turn: a
2817
+ * false boundary published the answer on the continuation row, or the CLI
2818
+ * folded the queued prompt into the turn it had already begun. In the
2819
+ * second case nothing attaches the entry anymore, and it held every later
2820
+ * message out of the worker until the watchdog failed it with a "resend"
2821
+ * error for a message that had been answered (seen live 2026-09-27).
2822
+ *
2823
+ * Told apart by silence: the continuation published its answer, no turn
2824
+ * started since (any init takes the entry out, see the init handler), and
2825
+ * the stream stayed quiet for the settle window, counted from whichever is
2826
+ * latest: the continuation's end, the entry's push, the last stream event.
2827
+ * Same gates as the fold reaper, so never during a compaction, an API
2828
+ * retry, an open control round-trip or a task continuation that is due. A
2829
+ * continuation that was stopped, failed or never reached the relay settles
2830
+ * nothing: the marker points at its reply, and the watchdog keeps those
2831
+ * rows.
2832
+ */
2833
+ function reapOvertakenDeliveredEntries(now) {
2834
+ if (!proc || !proc.pendingDelivered.length) return;
2835
+ if (!(strandedSettleMs > 0)) return;
2836
+ if (proc.activeCtx || proc.pendingControlRequests > 0 || isCompacting() || isContinuationImminent()
2837
+ || isApiRetryPending() || isStopFollowUpPending(now)) {
2838
+ for (const entry of proc.pendingDelivered) entry.overtakenSince = 0;
2839
+ return;
2840
+ }
2841
+ const answered = [];
2842
+ proc.pendingDelivered = proc.pendingDelivered.filter((entry) => {
2843
+ if (entry.steered || !publishedItsAnswer(entry.overtakenBy)) return true;
2844
+ if (!entry.overtakenSince) { entry.overtakenSince = now; return true; }
2845
+ // Any stream event restarts the wait: a CLI that still talks is not done.
2846
+ const quietSince = Math.max(entry.overtakenSince, entry.pushedAt || 0, proc.lastEventAt);
2847
+ if (now - quietSince < strandedSettleMs) return true;
2848
+ answered.push(entry);
2849
+ // Owned until the marker is committed (same claim as a folded steer).
2850
+ claimSettling(entry.ctx.message, 'answeredElsewhere');
2851
+ return false;
2852
+ });
2853
+ if (!answered.length) return;
2854
+ void (async () => {
2855
+ for (const entry of answered) {
2856
+ dbg(
2857
+ 'delivered message was answered on a continuation row; settling it',
2858
+ shortMessageId(entry.ctx.message),
2859
+ `continuation=${shortMessageId(entry.overtakenBy.message)}`,
2860
+ );
2861
+ try {
2862
+ await settleOvertakenDeliveredEntry(entry);
2863
+ } catch (error) {
2864
+ dbg('overtaken-delivery settle failed', error?.message || String(error));
2865
+ }
2866
+ }
2867
+ })();
2868
+ }
2869
+
2870
+ async function settleOvertakenDeliveredEntry(entry) {
2871
+ const ctx = entry.ctx;
2872
+ if (ctx.finalized) {
2873
+ releaseSettling(ctx.message?.id);
2874
+ return;
2875
+ }
2876
+ ctx.finalized = true;
2877
+ const model = entry.overtakenBy.state.responseModel || proc?.model || null;
2878
+ try {
2879
+ // Marked consumed BEFORE the first publish attempt: should the worker
2880
+ // die while the marker is retried, the relay fails the row instead of
2881
+ // running its prompt again. Never rejects (best-effort).
2882
+ await markConsumed(ctx.message?.conversationId, [ctx.message]);
2883
+ // The row closes with a pointer to the answer, under a kind of its own:
2884
+ // a marker, not a reply. Not requeued (the prompt would run twice) and
2885
+ // not failed (it was answered).
2886
+ await publishSettleMarker(ctx.message, {
2887
+ text: DELIVERY_ANSWERED_ELSEWHERE_TEXT,
2888
+ model,
2889
+ kind: ANSWERED_ELSEWHERE_KIND,
2890
+ variant: 'answeredElsewhere',
2891
+ });
2892
+ } finally {
2893
+ // Spliced out of pendingDelivered already: nothing else resolves it.
2894
+ ctx.resolveDone?.(true);
2895
+ }
2896
+ }
2897
+
2533
2898
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2534
2899
 
2535
2900
  /**
@@ -2563,7 +2928,14 @@ export function createClaudeSessionRunner({
2563
2928
  const workflowRuns = publisher.drainWorkflowRuns();
2564
2929
  try {
2565
2930
  for (let attempt = 0; ; attempt += 1) {
2566
- const outcome = await publisher.publishResponse(message, { text, model, kind, workflowRuns });
2931
+ // Never requeued, with or without a kind: the CLI has the prompt.
2932
+ const outcome = await publisher.publishResponse(message, {
2933
+ text,
2934
+ model,
2935
+ kind,
2936
+ workflowRuns,
2937
+ requeueOnFailure: false,
2938
+ });
2567
2939
  reportFirstAttempt();
2568
2940
  if (outcome !== 'failed') return;
2569
2941
  if (attempt >= settleRetryDelaysMs.length) break;
@@ -2655,10 +3027,14 @@ export function createClaudeSessionRunner({
2655
3027
  if (proc.stopFollowUp && !proc.suppressingTurn && !proc.pendingDelivered.some((entry) => entry.stoppedWithTurn)) {
2656
3028
  proc.stopFollowUp = null;
2657
3029
  }
3030
+ noteQuietTurn();
2658
3031
  reapSilentProvisionalAdoption(now);
2659
3032
  // Before the 5-minute watchdog: a folded steered entry settles as merged
2660
3033
  // within the short fold grace instead of failing over.
2661
3034
  reapFoldedSteeredEntries(now);
3035
+ // Also before the watchdog: a message a continuation answered settles as
3036
+ // answered instead of failing over minutes later.
3037
+ reapOvertakenDeliveredEntries(now);
2662
3038
  reapStalePendingDelivered(now);
2663
3039
  if (hasLiveWork()) {
2664
3040
  // Track how long background tasks alone have held the process, for the
@@ -2765,6 +3141,7 @@ export function createClaudeSessionRunner({
2765
3141
  await publisher.publishTurnException({
2766
3142
  message: ctx.message,
2767
3143
  errorText: String(streamError?.message || streamError || 'unknown error'),
3144
+ usageLimit: usageLimit.classifyException(streamError),
2768
3145
  }).catch(() => {});
2769
3146
  }
2770
3147
  continue;
@@ -2855,8 +3232,23 @@ export function createClaudeSessionRunner({
2855
3232
  activeCtx: null,
2856
3233
  pendingDelivered: [],
2857
3234
  lastBoundary: null,
3235
+ // What marked the boundary, and whether that alone proves the CLI
3236
+ // opened the turn; read only while lastBoundary is 'self-opened'.
3237
+ boundaryReason: '',
3238
+ boundaryCorroborated: false,
3239
+ // An init arrived after the boundary was armed, with a delivered
3240
+ // message waiting: a newer turn started in place of the announced one.
3241
+ boundarySuperseded: false,
3242
+ // taskSettleSeq when a continuation context last opened: a settle since
3243
+ // then still has its continuation coming.
3244
+ settleSeqAtContinuationOpen: 0,
2858
3245
  pendingControlRequests: 0,
2859
3246
  lastEventAt: Date.now(),
3247
+ // The quiet-turn log (see noteQuietTurn): what the CLI has in flight,
3248
+ // messages being handled right now, and when the silence was logged.
3249
+ liveness: createTurnLiveness({ windows: quietWindows }),
3250
+ dispatching: 0,
3251
+ quietLoggedAt: 0,
2860
3252
  heldForTasksSince: 0,
2861
3253
  taskSettledAt: 0,
2862
3254
  // Bumped by every task-settle signal; the Stop guard compares it.
@@ -3356,7 +3748,7 @@ export function createClaudeSessionRunner({
3356
3748
  settingsWindowOpen = false;
3357
3749
  const errorText = String(error?.message || error || 'unknown error');
3358
3750
  dbg('claude turn failed', message.id, errorText);
3359
- await publisher.publishTurnException({ message, errorText });
3751
+ await publisher.publishTurnException({ message, errorText, usageLimit: usageLimit.classifyException(error) });
3360
3752
  return true;
3361
3753
  } finally {
3362
3754
  endAdmission();
@@ -13,6 +13,7 @@ import {
13
13
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
14
14
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
15
15
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
16
+ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
16
17
  import { createControlPoller } from '../../shared/control-poller.mjs';
17
18
  import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
18
19
  import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
@@ -20,6 +21,10 @@ import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
20
21
  import { createClaudeSessionRunner } from './claude-session-process.mjs';
21
22
  import { createRunnerLinkBridge, failSettlingRowsOnShutdown } from './claude-worker-link-wiring.mjs';
22
23
 
24
+ // Windows console launch: the launcher names the worker log, this copies
25
+ // stdout/stderr into it. A no-op wherever the launcher redirects instead.
26
+ installWorkerLogFile();
27
+
23
28
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
29
  const HEARTBEAT_MS = 10_000;
25
30