@oar-sh/oar 0.9.4 → 0.9.5

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/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ All notable changes to OAR are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.5] — 2026-09-27
9
+
10
+ Highlights: a Claude reply on the wrong row no longer blocks the conversation,
11
+ workers on Windows write a log, and agents can set the effort of a session on
12
+ another relay.
13
+
14
+ ### Added
15
+
16
+ - **Remote relays**: an agent can set the reasoning effort of a session it starts or
17
+ prompts on another relay (`effort` on `create_session` and `send`), and `relay_info`
18
+ lists the efforts each model there takes.
19
+
20
+ ### Changed
21
+
22
+ - Session workers on Windows now write the same `worker-<session>.log` as on
23
+ Linux, in the relay's log directory; the console window keeps showing the
24
+ output.
25
+
26
+ ### Fixed
27
+
28
+ - **Remote relays:** waiting for a remote agent's reply no longer stops at an
29
+ interim answer ("still waiting for …") while the agent goes on in a turn of
30
+ its own without a background task, for example while its subagents work.
31
+ - A Claude reply published as a *background continuation* instead of under
32
+ your message no longer blocks the conversation for up to 5 minutes: after
33
+ about 45 seconds of silence your message closes with a small muted note
34
+ naming that reply, and the next message runs normally. The note sends no
35
+ second notification, and an agent on a paired relay receives the reply
36
+ itself, not the note. A stray re-init of the Claude CLI between turns no
37
+ longer sends the reply there in the first place.
38
+
8
39
  ## [0.9.4] — 2026-09-27
9
40
 
10
41
  Highlights: remote relays (pair OAR relays and let an agent on one work on the
package/README.md CHANGED
@@ -78,7 +78,7 @@ curl -fsSL oar.sh/install | sh
78
78
  The script checks for Node.js 22.13 or newer, runs `npm install -g @oar-sh/oar`, and then hands off to `oar setup` (with `--defaults` when there is no terminal to ask questions in). It refuses to run as root, because OAR installs per user. `OAR_VERSION=x.y.z` pins a version, `OAR_CHANNEL=beta` follows the beta channel, and `OAR_DRY_RUN=1` only prints what it would do:
79
79
 
80
80
  ```bash
81
- curl -fsSL oar.sh/install | OAR_VERSION=0.9.4 sh
81
+ curl -fsSL oar.sh/install | OAR_VERSION=0.9.5 sh
82
82
  ```
83
83
 
84
84
  Read it before you run it: [oar.sh/install](https://oar.sh/install).
@@ -89,7 +89,7 @@ Read it before you run it: [oar.sh/install](https://oar.sh/install).
89
89
  irm oar.sh/install.ps1 | iex
90
90
  ```
91
91
 
92
- The PowerShell installer (PowerShell 5 or newer) does the same: it checks Node.js, runs `npm install -g @oar-sh/oar`, then `oar setup`. It reads the same `OAR_VERSION`, `OAR_CHANNEL`, and `OAR_DRY_RUN` variables, for example `$env:OAR_VERSION = '0.9.4'` before the command.
92
+ The PowerShell installer (PowerShell 5 or newer) does the same: it checks Node.js, runs `npm install -g @oar-sh/oar`, then `oar setup`. It reads the same `OAR_VERSION`, `OAR_CHANNEL`, and `OAR_DRY_RUN` variables, for example `$env:OAR_VERSION = '0.9.5'` before the command.
93
93
 
94
94
  ### npm
95
95
 
@@ -326,6 +326,13 @@ Use **🤗 Select Models** to choose which variants show up in the composer, the
326
326
 
327
327
  ## Highlights
328
328
 
329
+ ### New in 0.9.5
330
+
331
+ - **A misplaced Claude reply no longer blocks the conversation**: when a reply is published as a *background continuation* instead of under your message, the message closes after about 45 seconds of silence with a small note naming that reply, and the next message runs normally. It used to hold every later message for up to 5 minutes.
332
+ - **Effort for remote sessions**: an agent can set the reasoning effort of a session it starts or prompts on another relay; a new session takes the calling session's effort when the model there supports it. See [Remote relays](#remote-relays).
333
+ - **Waiting on another relay is steadier**: the wait no longer stops at an interim answer while the remote agent goes on in a turn of its own, for example while its subagents work.
334
+ - **Worker logs on Windows**: session workers now write the same `worker-<session>.log` as on Linux, in the relay's log directory, while the console window keeps showing the output.
335
+
329
336
  ### New in 0.9.4
330
337
 
331
338
  - **Remote relays**: pair two OAR relays in **Settings → Relays** by pasting the other relay's web address; the pairing works both ways. An agent in any session can then work on the other relay when you ask it to: list and read its sessions, prompt one, start a new one, wait for the reply, and pass a question the remote agent asks back to you. See [Remote relays](#remote-relays).
@@ -442,7 +449,9 @@ Use **➡️ Share conversation** in the `⋯` menu to publish a read-only link.
442
449
 
443
450
  ### The conversation menu
444
451
 
445
- Besides the entries above, the `⋯` menu holds **✍️ Edit conversation title**, **🖥️ Inspect tmux console** (a read-only view of the session's tmux pane), **🤗 Select Models**, **⚙️ Settings**, **🌄 Restart web relay** (queued until the current turn is idle), **💤 Suspend host**, and **☠️ Kill session** (stops the conversation's worker; an active turn then needs a retry or a new message). Its header shows the queue counts with **🚮 Empty queue**.
452
+ Besides the entries above, the `⋯` menu holds **✍️ Edit conversation title**, **🖥️ Inspect tmux console** (a read-only view of the session's tmux pane), **🤗 Select Models**, **⚙️ Settings**, **🌄 Restart web relay** (queued until the current turn is idle), **💤 Suspend host** (Windows; see below), and **☠️ Kill session** (stops the conversation's worker; an active turn then needs a retry or a new message). Its header shows the queue counts with **🚮 Empty queue**.
453
+
454
+ **💤 Suspend host** puts the machine the relay runs on to sleep, once the work is done. The confirmation lists what is still active. The relay then queues the suspend and fires it after 2 minutes with nothing active: no queued, running or parked turn, no background task of a live worker, and no open GitHub Actions run in the repository of a conversation that was busy since the request (read through `gh`; a repository whose state cannot be read blocks for 15 minutes, then is ignored). With nothing running when you confirm, it fires after a 30-second countdown. While a suspend is queued, every device shows a banner with the blockers or the countdown and a **Cancel** button, and a push notification reports the suspend. A relay restart drops a queued suspend and says so. **Show Suspend host action** in Settings hides the entry per browser.
446
455
 
447
456
  ## Settings and configuration reference
448
457
 
@@ -539,6 +548,8 @@ The config file is `server/config.json` in a git checkout and `~/.oar/config.jso
539
548
  | `COPILOT_WEB_RELAY_LOG_DIR` | Log directory |
540
549
  | `OAR_STATE_ROOT` | State root of a global install, instead of `~/.oar` / `%APPDATA%\oar` |
541
550
  | `OAR_NO_UPDATE_CHECK=1` | Never contact oar.sh, not even for a manual update check |
551
+ | `OAR_HOST_SUSPEND_DRY_RUN=1` | **Suspend host** logs the suspend instead of sleeping the machine |
552
+ | `OAR_MCP_SERVER=0` | Do not attach OAR's MCP server to Grok and the Copilot extension engine; they get the preview instructions as text again, and no `remote_relay` tool |
542
553
  | `COPILOT_SDK_PATH` | The Copilot CLI runtime's `copilot-sdk` directory, instead of the newest one in the CLI's package cache |
543
554
  | `COPILOT_PKG_DIR` | An additional Copilot CLI package cache to search |
544
555
  | `GH_TOKEN`, `GITHUB_TOKEN` | GitHub token for the Copilot usage card, instead of `gh auth token` |
@@ -673,7 +684,9 @@ chain of relays prompting relays stops after two hops.
673
684
  Copilot directly; Grok and the Copilot extension engine through OAR's MCP server). Per paired
674
685
  relay, **Agents may** limits it to *read only*, *read and prompt* (existing sessions) or *full*
675
686
  (also create sessions, answer questions, stop turns, archive). In the *ask* and *plan* relay
676
- modes, every write action first asks you with a question card.
687
+ modes, every write action first asks you with a question card. For a session it starts, an agent
688
+ can set the provider, model, relay mode and reasoning effort; what it leaves out follows the
689
+ session it works in, as far as the other relay offers it.
677
690
 
678
691
  When an agent prompts a session on another relay, that relay shows a **↗ from …** badge with the
679
692
  sending relay, session and model on the message, and marks sessions an agent created in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -63,6 +63,7 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
63
63
  model: text('model'),
64
64
  cwd: text('cwd'),
65
65
  mode: choice('mode', REMOTE_RELAY_MODES),
66
+ effort: text('effort'),
66
67
  title: text('title'),
67
68
  question_id: text('question_id'),
68
69
  answer: text('answer'),
@@ -37,7 +37,12 @@ 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 {
41
+ ANSWERED_ELSEWHERE_KIND,
42
+ DELIVERY_ANSWERED_ELSEWHERE_TEXT,
43
+ STEER_FOLDED_TEXT,
44
+ STEER_STOPPED_TEXT,
45
+ } from '../../shared/steer-settle-markers.mjs';
41
46
  import { stripSteerNote, withSteerNoteContent } from '../../shared/steer-note.mjs';
42
47
  import {
43
48
  stripSlashCommandGuard,
@@ -464,6 +469,17 @@ export function createClaudeSessionRunner({
464
469
  // takes to distinguish a fold from a queued run. Far below the watchdog so a
465
470
  // folded row settles as answered, not failed with a "resend" error.
466
471
  steeredFoldGraceMs = 2_000,
472
+ // How long a NON-steered entry may sit unattached after a continuation that
473
+ // opened over it has published its answer, with the stream quiet and no
474
+ // turn starting, before it is settled as answered by that continuation. The
475
+ // CLI starts the turn of a message queued behind another one when that
476
+ // turn ends, and every turn opens with an init, which takes the entry out
477
+ // of this settle for good. So the window only has to outlast the gap up to
478
+ // that init (input processing, hooks); it is sized well above it because a
479
+ // settle that comes too early costs the user the real answer's place.
480
+ // Without the settle the row held every later message out of the worker
481
+ // until the watchdog failed it minutes later. 0 = disabled.
482
+ strandedSettleMs = 45_000,
467
483
  // Backstop for the compaction hold. A compaction is announced ONCE by
468
484
  // `status: 'compacting'` (the CLI's periodic re-emit is gated on the
469
485
  // remote-control client's activity callback, which the plain SDK `query()`
@@ -793,6 +809,8 @@ export function createClaudeSessionRunner({
793
809
  function attachDeliveredContext(index = 0, { provisional = false } = {}) {
794
810
  const [entry] = proc.pendingDelivered.splice(index, 1);
795
811
  entry.ctx.deliveredEntry = entry;
812
+ // It has a turn now; an adoption that is unwound puts it back unmarked.
813
+ entry.overtakenBy = null;
796
814
  // A provisional (post-compaction) attach may still be unwound back onto
797
815
  // the queue; its un-mark waits for the adoption to commit.
798
816
  if (!provisional) unmarkConsumedOnOwnTurn(entry);
@@ -869,7 +887,89 @@ export function createClaudeSessionRunner({
869
887
  function handBackAdoptedContext(ctx) {
870
888
  restoreAdoptedContext(ctx);
871
889
  dbg('post-compaction adoption handed back to a task continuation', ctx.message?.id || '(no id)');
872
- return openContinuationContext();
890
+ return openContinuationContext({ evidence: 'a task notification replayed into the adopted turn' });
891
+ }
892
+
893
+ /**
894
+ * Mark the next turn as one the CLI opened by itself. `corroborated` says
895
+ * whether the signal proves that on its own (a turn opener's replay, the
896
+ * Stop guard standing down) or only suggests it (a bare init, which the CLI
897
+ * also emits for reasons of its own). Sticky until the boundary is spent: a
898
+ * weaker signal behind a proven one does not take the proof back.
899
+ *
900
+ * Every marking starts with nothing superseding it, a re-marking of an
901
+ * armed boundary included. A bare init marks only with nothing queued, so
902
+ * it precedes any later push; and a boundary can stay armed with the flag
903
+ * set, when the message whose init set it was failed by the watchdog
904
+ * without a turn ever opening. Left standing, that flag made the next turn
905
+ * the CLI opened by itself look superseded by an init it never saw.
906
+ */
907
+ function markSelfOpenedBoundary(reason, { corroborated = false } = {}) {
908
+ if (proc.lastBoundary === 'self-opened' && proc.boundaryCorroborated) return;
909
+ proc.lastBoundary = 'self-opened';
910
+ proc.boundarySuperseded = false;
911
+ proc.boundaryCorroborated = corroborated;
912
+ proc.boundaryReason = reason;
913
+ }
914
+
915
+ /**
916
+ * What says the CLI really opened the turn the boundary announces, or ''
917
+ * when nothing does. Beyond the signal itself:
918
+ * - a task settled and no continuation has opened since. Counted, not
919
+ * timed, and not reset by a turn ending: a task that settles early in a
920
+ * long turn has its continuation open after that turn, by which time
921
+ * both the notification grace and `settleSeqAtTurnEnd` have moved on.
922
+ * Over-long by design: a settle whose notification was silent or folded
923
+ * into another turn stays evidence until the next continuation opens,
924
+ * which only keeps a boundary the way it was always kept;
925
+ * - a live task, which can report back without settling;
926
+ * - order: the init that armed the boundary came before the waiting
927
+ * message was pushed (an init arms it only with nothing queued), so that
928
+ * turn is not the message's own, and no later init has started another
929
+ * turn in its place (see `boundarySuperseded`).
930
+ */
931
+ function selfOpenedEvidence() {
932
+ if (proc.boundaryCorroborated) return proc.boundaryReason;
933
+ if (proc.taskSettleSeq !== proc.settleSeqAtContinuationOpen) {
934
+ return 'a background task settled and no continuation has opened since';
935
+ }
936
+ if (proc.liveTasks.size > 0) return 'a live background task can report back';
937
+ if (!proc.boundarySuperseded) return 'its init came before the message was pushed and no turn has started since';
938
+ return '';
939
+ }
940
+
941
+ /**
942
+ * A self-opened boundary diverts the next turn's answer to a continuation
943
+ * row. One armed by a bare init stays armed until a turn opens, however
944
+ * long that takes, and the CLI inits for reasons of its own: read as
945
+ * self-opened, a settings re-init published the user's answer on a
946
+ * continuation and left the delivered row unattached, holding every later
947
+ * message out until the watchdog failed it (see the init handler).
948
+ *
949
+ * Every turn opens with an init, so what tells a stale boundary from a live
950
+ * one is what follows the push. Traffic with no init in between belongs to
951
+ * the turn the armed init opened: a continuation, as before. A SECOND init,
952
+ * after the push, starts a newer turn; the armed one never produced
953
+ * anything, and unless a task corroborates it the boundary is dropped here
954
+ * and the turn attaches to the waiting message, as if it had never been
955
+ * marked. Steered entries do not count: they settle by the fold reaper. A
956
+ * boundary marked by a replay is never dropped; if that one is false, the
957
+ * settle in reapOvertakenDeliveredEntries recovers.
958
+ */
959
+ function disownUnprovenBoundary() {
960
+ if (proc.lastBoundary !== 'self-opened') return;
961
+ const waiting = proc.pendingDelivered.find((entry) => !entry.steered);
962
+ if (!waiting || selfOpenedEvidence()) return;
963
+ dbg(
964
+ 'self-opened boundary has no corroboration; the turn goes to the waiting message',
965
+ shortMessageId(waiting.ctx.message),
966
+ `boundary=${proc.boundaryReason}`,
967
+ );
968
+ proc.lastBoundary = null;
969
+ }
970
+
971
+ function shortMessageId(message) {
972
+ return String(message?.id || '').slice(0, 8) || '(no id)';
873
973
  }
874
974
 
875
975
  /**
@@ -879,7 +979,7 @@ export function createClaudeSessionRunner({
879
979
  * discards the turn's relay output (it still lands in the native
880
980
  * transcript) rather than failing the process.
881
981
  */
882
- function openContinuationContext() {
982
+ function openContinuationContext({ evidence = '' } = {}) {
883
983
  const ctx = createContext('continuation', {
884
984
  id: null,
885
985
  conversationId: sdkSessionId,
@@ -887,6 +987,26 @@ export function createClaudeSessionRunner({
887
987
  model: '',
888
988
  });
889
989
  ctx.registered = false;
990
+ // Delivered messages this turn opens over. Remembered on the entry: if
991
+ // this turn turns out to have taken their prompt, nothing will ever open
992
+ // for them (see reapOvertakenDeliveredEntries). Read before the
993
+ // activation below spends the boundary.
994
+ const overtaken = proc.pendingDelivered.filter((entry) => !entry.steered);
995
+ if (overtaken.length) {
996
+ const selfOpened = proc.lastBoundary === 'self-opened';
997
+ dbg(
998
+ 'continuation opens while a delivered message waits unattached',
999
+ overtaken.map((entry) => shortMessageId(entry.ctx.message)).join(','),
1000
+ `boundary=${selfOpened ? proc.boundaryReason : 'none'}`,
1001
+ `reason=${evidence || (selfOpened && selfOpenedEvidence()) || 'none'}`,
1002
+ );
1003
+ for (const entry of overtaken) {
1004
+ entry.overtakenBy = ctx;
1005
+ entry.overtakenSince = 0;
1006
+ }
1007
+ }
1008
+ // Whatever settled up to here has its continuation now.
1009
+ proc.settleSeqAtContinuationOpen = proc.taskSettleSeq;
890
1010
  activateContext(ctx);
891
1011
  (async () => {
892
1012
  // One idempotency key for the whole loop: a retry whose predecessor was
@@ -1149,11 +1269,21 @@ export function createClaudeSessionRunner({
1149
1269
  // A turn the CLI opened on its own (task-notification replay). The
1150
1270
  // context opens lazily on its first real traffic so a bookkeeping
1151
1271
  // replay that ends in a phantom result never creates a relay turn.
1152
- proc.lastBoundary = 'self-opened';
1272
+ // A turn opener that matched no delivered message proves the turn is
1273
+ // the CLI's own, tagged or not: the adoption rules above rest on that.
1274
+ const originKind = String(sdkMessage?.origin?.kind || '').trim();
1275
+ markSelfOpenedBoundary(
1276
+ isTaskNotificationReplay(sdkMessage)
1277
+ ? 'a task notification replay'
1278
+ : `an unmatched user replay (origin ${originKind || 'none'})`,
1279
+ { corroborated: true },
1280
+ );
1281
+ turnStartedForWaitingDeliveries();
1153
1282
  return null;
1154
1283
  }
1155
1284
 
1156
1285
  if (type === 'assistant' || type === 'stream_event' || type === 'user') {
1286
+ disownUnprovenBoundary();
1157
1287
  if (proc.lastBoundary !== 'self-opened') {
1158
1288
  // A background subagent's frames arrive at top level carrying
1159
1289
  // `parent_tool_use_id` (same marker the adoption commit checks): they
@@ -1177,6 +1307,7 @@ export function createClaudeSessionRunner({
1177
1307
  proc.lastBoundary = null;
1178
1308
  return null;
1179
1309
  }
1310
+ disownUnprovenBoundary();
1180
1311
  if (proc.lastBoundary !== 'self-opened' && proc.pendingDelivered.length && !sdkMessage?.parent_tool_use_id) {
1181
1312
  return attachDeliveredContext();
1182
1313
  }
@@ -1971,11 +2102,21 @@ export function createClaudeSessionRunner({
1971
2102
  if (type !== 'system') return;
1972
2103
  const subtype = String(sdkMessage.subtype || '');
1973
2104
  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.
2105
+ // The CLI emits an init at the start of EVERY turn, after it has taken
2106
+ // the turn's input and before the API request (read in the CLI, 2.1.282):
2107
+ // a delivered message's own turn opens init → traffic, with no user
2108
+ // replay, exactly like a continuation the CLI opens by itself. The
2109
+ // FIRST init of a process is the spawn's, and a spawn is always driven
2110
+ // by a delivered message.
1977
2111
  const firstInit = !proc.sawInit;
1978
2112
  proc.sawInit = true;
2113
+ // A turn is starting while delivered messages wait, and it may be
2114
+ // theirs: they leave the overtaken settle for good. Whether a context is
2115
+ // still active does not matter. A continuation whose result arrived
2116
+ // while its row was still registering is active when the next turn's
2117
+ // init lands, and skipping that init settled the message with a pointer
2118
+ // and dropped its real answer.
2119
+ if (!firstInit && proc.pendingDelivered.length) turnStartedForWaitingDeliveries();
1979
2120
  persistNativeSessionId(sdkSessionId, sdkMessage.session_id).catch(() => {});
1980
2121
  // Tracked on the process too (not only through the persist round-trip):
1981
2122
  // the workflow poller needs the native id to find the session dir even
@@ -2010,17 +2151,20 @@ export function createClaudeSessionRunner({
2010
2151
  // same way.
2011
2152
  if (settingsReinitsOwed > 0) settingsReinitsOwed -= 1;
2012
2153
  else if (!settingsWindowOpen || proc.taskSettleSeq !== proc.settleSeqAtTurnEnd) {
2013
- proc.lastBoundary = 'self-opened';
2154
+ markSelfOpenedBoundary('a bare init with nothing queued');
2014
2155
  }
2015
2156
  } 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.
2157
+ // A LATER init, with a delivered row waiting: a turn is starting, and
2158
+ // the init cannot say whose. It may be the CLI opening a turn of its
2159
+ // own (the live-verified continuation shape is a bare init with no
2160
+ // user replay at all) or the waiting message's own turn, which opens
2161
+ // with an init just the same. For a compaction inside that turn the
2162
+ // flag errs toward the continuation: adopting the queued row for a
2163
+ // turn that was the CLI's own closes it with another turn's answer,
2164
+ // and in this shape nothing later replays to correct it. When the
2165
+ // turn was the message's own, its answer publishes on a continuation
2166
+ // row instead and the row is settled with a pointer to it (see
2167
+ // reapOvertakenDeliveredEntries).
2024
2168
  //
2025
2169
  // Structural, not a clock. Gating this on "a task settled recently"
2026
2170
  // fails twice over: the grace (60 s) is shorter than a compaction
@@ -2036,6 +2180,9 @@ export function createClaudeSessionRunner({
2036
2180
  // continuation). This suppresses adoption and nothing else; if the turn
2037
2181
  // really is the delivered message's, it attaches exactly as before.
2038
2182
  proc.continuationInitPending = true;
2183
+ // A boundary an earlier init armed announced a turn that never
2184
+ // produced anything: this init starts a newer one in its place.
2185
+ if (proc.lastBoundary === 'self-opened') proc.boundarySuperseded = true;
2039
2186
  }
2040
2187
  return;
2041
2188
  }
@@ -2341,7 +2488,9 @@ export function createClaudeSessionRunner({
2341
2488
  if (suppressed === 'observe-only') return;
2342
2489
  // Its traffic opens a continuation of its own instead of attaching to the
2343
2490
  // stopped steer waiting in pendingDelivered (which still settles stopped).
2344
- if (suppressed === 'continuation') proc.lastBoundary = 'self-opened';
2491
+ if (suppressed === 'continuation') {
2492
+ markSelfOpenedBoundary('the stop guard stood down for a task continuation', { corroborated: true });
2493
+ }
2345
2494
  const ctx = resolveContext(sdkMessage);
2346
2495
  if (proc.handoffSettling) {
2347
2496
  // An absorbed-steering handoff just settled the previous context;
@@ -2530,6 +2679,112 @@ export function createClaudeSessionRunner({
2530
2679
  }
2531
2680
  }
2532
2681
 
2682
+ /**
2683
+ * A turn is starting while delivered messages wait. Whatever opened over
2684
+ * them before, they are no longer known to be left behind: this turn may be
2685
+ * theirs, and its first traffic can take longer than the settle window.
2686
+ */
2687
+ function turnStartedForWaitingDeliveries() {
2688
+ for (const entry of proc.pendingDelivered) entry.overtakenBy = null;
2689
+ }
2690
+
2691
+ /** The continuation ran to a clean end and its answer is on the relay. */
2692
+ function publishedItsAnswer(ctx) {
2693
+ return Boolean(ctx)
2694
+ && ctx.finalized
2695
+ && ctx.registered
2696
+ && !ctx.discarded
2697
+ && !ctx.interrupted
2698
+ && Boolean(ctx.message?.id)
2699
+ && Boolean(ctx.state.result)
2700
+ && !ctx.state.result.isError;
2701
+ }
2702
+
2703
+ /**
2704
+ * Settle delivered messages whose prompt a continuation's turn took. A
2705
+ * continuation opened over a waiting NON-steered entry either ran ahead of
2706
+ * the entry's own turn, which then follows at once, or WAS that turn: a
2707
+ * false boundary published the answer on the continuation row, or the CLI
2708
+ * folded the queued prompt into the turn it had already begun. In the
2709
+ * second case nothing attaches the entry anymore, and it held every later
2710
+ * message out of the worker until the watchdog failed it with a "resend"
2711
+ * error for a message that had been answered (seen live 2026-09-27).
2712
+ *
2713
+ * Told apart by silence: the continuation published its answer, no turn
2714
+ * started since (any init takes the entry out, see the init handler), and
2715
+ * the stream stayed quiet for the settle window, counted from whichever is
2716
+ * latest: the continuation's end, the entry's push, the last stream event.
2717
+ * Same gates as the fold reaper, so never during a compaction, an API
2718
+ * retry, an open control round-trip or a task continuation that is due. A
2719
+ * continuation that was stopped, failed or never reached the relay settles
2720
+ * nothing: the marker points at its reply, and the watchdog keeps those
2721
+ * rows.
2722
+ */
2723
+ function reapOvertakenDeliveredEntries(now) {
2724
+ if (!proc || !proc.pendingDelivered.length) return;
2725
+ if (!(strandedSettleMs > 0)) return;
2726
+ if (proc.activeCtx || proc.pendingControlRequests > 0 || isCompacting() || isContinuationImminent()
2727
+ || isApiRetryPending() || isStopFollowUpPending(now)) {
2728
+ for (const entry of proc.pendingDelivered) entry.overtakenSince = 0;
2729
+ return;
2730
+ }
2731
+ const answered = [];
2732
+ proc.pendingDelivered = proc.pendingDelivered.filter((entry) => {
2733
+ if (entry.steered || !publishedItsAnswer(entry.overtakenBy)) return true;
2734
+ if (!entry.overtakenSince) { entry.overtakenSince = now; return true; }
2735
+ // Any stream event restarts the wait: a CLI that still talks is not done.
2736
+ const quietSince = Math.max(entry.overtakenSince, entry.pushedAt || 0, proc.lastEventAt);
2737
+ if (now - quietSince < strandedSettleMs) return true;
2738
+ answered.push(entry);
2739
+ // Owned until the marker is committed (same claim as a folded steer).
2740
+ claimSettling(entry.ctx.message, 'answeredElsewhere');
2741
+ return false;
2742
+ });
2743
+ if (!answered.length) return;
2744
+ void (async () => {
2745
+ for (const entry of answered) {
2746
+ dbg(
2747
+ 'delivered message was answered on a continuation row; settling it',
2748
+ shortMessageId(entry.ctx.message),
2749
+ `continuation=${shortMessageId(entry.overtakenBy.message)}`,
2750
+ );
2751
+ try {
2752
+ await settleOvertakenDeliveredEntry(entry);
2753
+ } catch (error) {
2754
+ dbg('overtaken-delivery settle failed', error?.message || String(error));
2755
+ }
2756
+ }
2757
+ })();
2758
+ }
2759
+
2760
+ async function settleOvertakenDeliveredEntry(entry) {
2761
+ const ctx = entry.ctx;
2762
+ if (ctx.finalized) {
2763
+ releaseSettling(ctx.message?.id);
2764
+ return;
2765
+ }
2766
+ ctx.finalized = true;
2767
+ const model = entry.overtakenBy.state.responseModel || proc?.model || null;
2768
+ try {
2769
+ // Marked consumed BEFORE the first publish attempt: should the worker
2770
+ // die while the marker is retried, the relay fails the row instead of
2771
+ // running its prompt again. Never rejects (best-effort).
2772
+ await markConsumed(ctx.message?.conversationId, [ctx.message]);
2773
+ // The row closes with a pointer to the answer, under a kind of its own:
2774
+ // a marker, not a reply. Not requeued (the prompt would run twice) and
2775
+ // not failed (it was answered).
2776
+ await publishSettleMarker(ctx.message, {
2777
+ text: DELIVERY_ANSWERED_ELSEWHERE_TEXT,
2778
+ model,
2779
+ kind: ANSWERED_ELSEWHERE_KIND,
2780
+ variant: 'answeredElsewhere',
2781
+ });
2782
+ } finally {
2783
+ // Spliced out of pendingDelivered already: nothing else resolves it.
2784
+ ctx.resolveDone?.(true);
2785
+ }
2786
+ }
2787
+
2533
2788
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2534
2789
 
2535
2790
  /**
@@ -2563,7 +2818,14 @@ export function createClaudeSessionRunner({
2563
2818
  const workflowRuns = publisher.drainWorkflowRuns();
2564
2819
  try {
2565
2820
  for (let attempt = 0; ; attempt += 1) {
2566
- const outcome = await publisher.publishResponse(message, { text, model, kind, workflowRuns });
2821
+ // Never requeued, with or without a kind: the CLI has the prompt.
2822
+ const outcome = await publisher.publishResponse(message, {
2823
+ text,
2824
+ model,
2825
+ kind,
2826
+ workflowRuns,
2827
+ requeueOnFailure: false,
2828
+ });
2567
2829
  reportFirstAttempt();
2568
2830
  if (outcome !== 'failed') return;
2569
2831
  if (attempt >= settleRetryDelaysMs.length) break;
@@ -2659,6 +2921,9 @@ export function createClaudeSessionRunner({
2659
2921
  // Before the 5-minute watchdog: a folded steered entry settles as merged
2660
2922
  // within the short fold grace instead of failing over.
2661
2923
  reapFoldedSteeredEntries(now);
2924
+ // Also before the watchdog: a message a continuation answered settles as
2925
+ // answered instead of failing over minutes later.
2926
+ reapOvertakenDeliveredEntries(now);
2662
2927
  reapStalePendingDelivered(now);
2663
2928
  if (hasLiveWork()) {
2664
2929
  // Track how long background tasks alone have held the process, for the
@@ -2855,6 +3120,16 @@ export function createClaudeSessionRunner({
2855
3120
  activeCtx: null,
2856
3121
  pendingDelivered: [],
2857
3122
  lastBoundary: null,
3123
+ // What marked the boundary, and whether that alone proves the CLI
3124
+ // opened the turn; read only while lastBoundary is 'self-opened'.
3125
+ boundaryReason: '',
3126
+ boundaryCorroborated: false,
3127
+ // An init arrived after the boundary was armed, with a delivered
3128
+ // message waiting: a newer turn started in place of the announced one.
3129
+ boundarySuperseded: false,
3130
+ // taskSettleSeq when a continuation context last opened: a settle since
3131
+ // then still has its continuation coming.
3132
+ settleSeqAtContinuationOpen: 0,
2858
3133
  pendingControlRequests: 0,
2859
3134
  lastEventAt: Date.now(),
2860
3135
  heldForTasksSince: 0,
@@ -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
 
@@ -20,6 +20,7 @@ import {
20
20
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
21
21
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
22
22
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
23
+ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
23
24
  import {
24
25
  createRunnerLinkBridge,
25
26
  failSettlingRowsOnShutdown,
@@ -33,6 +34,10 @@ import {
33
34
  } from '../../shared/worker-bootstrap.mjs';
34
35
  import { createCopilotSdkSessionRunner } from './copilot-sdk-session-process.mjs';
35
36
 
37
+ // Windows console launch: the launcher names the worker log, this copies
38
+ // stdout/stderr into it. A no-op wherever the launcher redirects instead.
39
+ installWorkerLogFile();
40
+
36
41
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
37
42
  const HEARTBEAT_MS = 10_000;
38
43
 
@@ -13,10 +13,15 @@ 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 { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
18
19
  import { createCursorTurnRunner } from './cursor-turn-runner.mjs';
19
20
 
21
+ // Windows console launch: the launcher names the worker log, this copies
22
+ // stdout/stderr into it. A no-op wherever the launcher redirects instead.
23
+ installWorkerLogFile();
24
+
20
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
26
  const HEARTBEAT_MS = 10_000;
22
27
 
@@ -12,10 +12,15 @@ import {
12
12
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
13
13
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
14
14
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
15
+ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
15
16
  import { createControlPoller } from '../../shared/control-poller.mjs';
16
17
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
17
18
  import { createGrokTurnRunner } from './grok-turn-runner.mjs';
18
19
 
20
+ // Windows console launch: the launcher names the worker log, this copies
21
+ // stdout/stderr into it. A no-op wherever the launcher redirects instead.
22
+ installWorkerLogFile();
23
+
19
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
25
  const HEARTBEAT_MS = 10_000;
21
26
 
@@ -1277,6 +1277,8 @@ function createMessageNode(msg, msgId = null, force = false) {
1277
1277
  // The settle marker of a steer the CLI consumed without a turn of its own:
1278
1278
  // folded into (and answered by) the turn above, or cut off unanswered by a
1279
1279
  // Stop. Compact, attached to its own user message, never merging forward.
1280
+ // Also the marker of a message answered on a continuation row, which only
1281
+ // shares the look.
1280
1282
  const steerMarkerClass = msg.role === 'assistant' ? STEER_MARKER_CLASS_BY_KIND[messageKind] : null;
1281
1283
  if (steerMarkerClass) div.classList.add(steerMarkerClass);
1282
1284
  const isStoppedSteer = msg.role === 'assistant' && messageKind === 'stopped';