@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 +31 -0
- package/README.md +17 -4
- package/package.json +1 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +293 -18
- package/server/claude-worker/claude-session-worker.mjs +5 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-session-worker.mjs +5 -0
- package/server/grok-worker/grok-session-worker.mjs +5 -0
- package/server/public/app/conversation-view.js +2 -0
- package/server/public/app/steered-turn-merge.mjs +10 -1
- package/server/public/index.html +7 -3
- package/server/routes/messages-routes.mjs +8 -3
- package/server/routes/sessions-routes.mjs +4 -2
- package/server/services/remote-relay-caller-context.mjs +8 -3
- package/server/services/remote-relay-dispatcher.mjs +256 -13
- package/server/services/session-worker-launch-service.mjs +17 -2
- package/shared/remote-relay-contract.mjs +17 -0
- package/shared/steer-settle-failure.mjs +6 -0
- package/shared/steer-settle-markers.mjs +16 -0
- package/shared/worker-runtime/worker-log-file.mjs +97 -0
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.
|
|
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.
|
|
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
|
|
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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
1975
|
-
//
|
|
1976
|
-
//
|
|
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
|
-
|
|
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
|
|
2017
|
-
//
|
|
2018
|
-
//
|
|
2019
|
-
//
|
|
2020
|
-
//
|
|
2021
|
-
//
|
|
2022
|
-
// turn
|
|
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')
|
|
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
|
-
|
|
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';
|