@oar-sh/oar 0.9.5 → 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 (63) 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 +168 -0
  4. package/README.md +20 -4
  5. package/package.json +2 -1
  6. package/server/claude-worker/claude-session-process.mjs +118 -1
  7. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  9. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  10. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  11. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  12. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  13. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  15. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  16. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  17. package/server/db-schema.mjs +7 -0
  18. package/server/grok-worker/acp-client.mjs +68 -12
  19. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  20. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  21. package/server/public/app/api-client.js +17 -2
  22. package/server/public/app/bootstrap.js +3 -0
  23. package/server/public/app/composer-control-state.mjs +4 -3
  24. package/server/public/app/conversation-view.js +95 -4
  25. package/server/public/app/journal-view.js +6 -0
  26. package/server/public/app/markdown-strikethrough.mjs +31 -0
  27. package/server/public/app/relay-board-inline.mjs +70 -0
  28. package/server/public/app/relay-board-view.js +157 -61
  29. package/server/public/app/relay-error-ctas.mjs +12 -3
  30. package/server/public/app/socket-handlers.js +9 -0
  31. package/server/public/app/store.js +4 -1
  32. package/server/public/app/subagent-fold.mjs +61 -0
  33. package/server/public/app/usage-limit-ui.js +225 -0
  34. package/server/public/index.html +90 -1
  35. package/server/repositories/message-repository.mjs +10 -1
  36. package/server/repositories/question-repository.mjs +1 -0
  37. package/server/repositories/remote-relay-repository.mjs +30 -0
  38. package/server/routes/messages-routes.mjs +85 -14
  39. package/server/routes/relay-board-routes.mjs +56 -2
  40. package/server/routes/sessions-routes.mjs +36 -0
  41. package/server/server-runtime.mjs +33 -2
  42. package/server/services/preview-proxy-server.mjs +64 -36
  43. package/server/services/process-list-service.mjs +122 -0
  44. package/server/services/relay-console-log.mjs +199 -0
  45. package/server/services/remote-relay-caller-context.mjs +37 -17
  46. package/server/services/remote-relay-dispatcher.mjs +72 -8
  47. package/server/services/remote-relay-inbound.mjs +35 -0
  48. package/server/services/session-worker-process-service.mjs +85 -32
  49. package/server/services/session-worker-registry-service.mjs +1 -1
  50. package/server/services/session-worker-supervisor-service.mjs +33 -1
  51. package/server/services/usage-limit-pause-service.mjs +441 -0
  52. package/shared/ask-user-bridge.mjs +14 -39
  53. package/shared/claude-usage-limit.mjs +257 -0
  54. package/shared/control-poller.mjs +69 -20
  55. package/shared/failure-note-text.mjs +38 -0
  56. package/shared/question-wait.mjs +108 -0
  57. package/shared/remote-relay-contract.mjs +16 -2
  58. package/shared/steer-settle-failure.mjs +16 -0
  59. package/shared/steer-settle-markers.mjs +8 -0
  60. package/shared/worker-runtime/process-tree.mjs +483 -0
  61. package/shared/worker-runtime/relay-errors.mjs +31 -0
  62. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  63. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -6,7 +6,7 @@ import {
6
6
  } from "../runtime/send-and-wait-errors.mjs";
7
7
  import { getActiveSession } from "../runtime/session-registry.mjs";
8
8
  import { DEFAULT_QUESTION_TIMEOUT_MS } from "../../../../shared/question-timeout.mjs";
9
- import { QUESTION_TIMEOUT_CONTINUATION_TEXT } from "../../../../shared/question-timeout.mjs";
9
+ import { waitForRelayQuestion } from "../../../../shared/question-wait.mjs";
10
10
  import { EMPTY_TURN_COMPLETION_NOTE } from "../../../../shared/empty-turn-completion.mjs";
11
11
  import { stripPromptContextPrefix } from "../skills/prompt-context.mjs";
12
12
 
@@ -473,37 +473,17 @@ export function createPollingLoop({
473
473
  return null;
474
474
  };
475
475
 
476
- async function waitForRelayQuestionAnswer(questionId, timeoutMs = DEFAULT_QUESTION_TIMEOUT_MS, pollIntervalMs = 1500) {
477
- const started = Date.now();
478
- while (true) {
479
- const { question } = await api("GET", `/api/relay-question/${questionId}`);
480
- if (!question) throw new Error("Relay question missing");
481
- if (question.status === "answered") {
482
- return {
483
- answer: String(question.answer || "").trim(),
484
- structuredAnswer: question.structuredAnswer && typeof question.structuredAnswer === "object"
485
- ? question.structuredAnswer
486
- : null,
487
- timedOut: false,
488
- };
489
- }
490
- if (question.status === "timed_out" || question.status === "cancelled") {
491
- return {
492
- answer: QUESTION_TIMEOUT_CONTINUATION_TEXT,
493
- structuredAnswer: null,
494
- timedOut: true,
495
- };
496
- }
497
- if (Date.now() - started >= timeoutMs) {
498
- await api("POST", `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
499
- return {
500
- answer: QUESTION_TIMEOUT_CONTINUATION_TEXT,
501
- structuredAnswer: null,
502
- timedOut: true,
503
- };
504
- }
505
- await sleep(pollIntervalMs);
506
- }
476
+ // The wait survives a relay that is briefly not there
477
+ // (shared/question-wait.mjs).
478
+ function waitForRelayQuestionAnswer(questionId, timeoutMs = DEFAULT_QUESTION_TIMEOUT_MS, pollIntervalMs = 1500) {
479
+ return waitForRelayQuestion({
480
+ api,
481
+ questionId,
482
+ deadlineMs: timeoutMs,
483
+ pollMs: pollIntervalMs,
484
+ sleep,
485
+ dbg,
486
+ });
507
487
  }
508
488
 
509
489
  async function processPendingSdkSessionDeletes() {
@@ -1,5 +1,5 @@
1
1
  import { getActiveSession } from '../runtime/session-registry.mjs';
2
- import { QUESTION_TIMEOUT_CONTINUATION_TEXT } from "../../../../shared/question-timeout.mjs";
2
+ import { createRelayQuestion, waitForRelayQuestion } from "../../../../shared/question-wait.mjs";
3
3
  import { extractRequestedSchema } from "../../../../shared/question-schema.mjs";
4
4
 
5
5
  export function createQuestionBridge({
@@ -49,39 +49,18 @@ export function createQuestionBridge({
49
49
  return Number.isFinite(fallback) && fallback >= 0 ? fallback : 0;
50
50
  }
51
51
 
52
- async function waitForRelayQuestionAnswer(questionId, timeoutMs = null) {
53
- const started = Date.now();
54
- const effectiveTimeoutMs = resolveQuestionWaitTimeoutMs(timeoutMs);
55
-
56
- while (true) {
57
- const { question } = await api("GET", `/api/relay-question/${questionId}`);
58
- if (!question) throw new Error("Relay question missing");
59
- if (question.status === "answered") {
60
- return {
61
- answer: String(question.answer || ""),
62
- structuredAnswer: question.structuredAnswer && typeof question.structuredAnswer === "object"
63
- ? question.structuredAnswer
64
- : null,
65
- timedOut: false,
66
- };
67
- }
68
- if (question.status === "timed_out" || question.status === "cancelled") {
69
- return {
70
- answer: QUESTION_TIMEOUT_CONTINUATION_TEXT,
71
- structuredAnswer: null,
72
- timedOut: true,
73
- };
74
- }
75
- if (Date.now() - started >= effectiveTimeoutMs) {
76
- await api("POST", `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
77
- return {
78
- answer: QUESTION_TIMEOUT_CONTINUATION_TEXT,
79
- structuredAnswer: null,
80
- timedOut: true,
81
- };
82
- }
83
- await sleep(questionPollMs);
84
- }
52
+ // The wait survives a relay that is briefly not there
53
+ // (shared/question-wait.mjs).
54
+ function waitForRelayQuestionAnswer(questionId, timeoutMs = null) {
55
+ return waitForRelayQuestion({
56
+ api,
57
+ questionId,
58
+ deadlineMs: resolveQuestionWaitTimeoutMs(timeoutMs),
59
+ pollMs: questionPollMs,
60
+ sleep,
61
+ trimAnswer: false,
62
+ dbg,
63
+ });
85
64
  }
86
65
 
87
66
  async function forwardRelayQuestion(request) {
@@ -112,11 +91,7 @@ export function createQuestionBridge({
112
91
  request: serializeRequest(request),
113
92
  };
114
93
 
115
- const created = await api("POST", "/api/relay-question", questionPayload);
116
- const questionId = created?.question?.id;
117
- if (!questionId) {
118
- throw new Error("Relay question could not be created");
119
- }
94
+ const questionId = await createRelayQuestion({ api, payload: questionPayload, sleep, dbg });
120
95
 
121
96
  dbg(
122
97
  "relay question created",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,174 @@ All notable changes to OAR are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.6] — 2026-09-29
9
+
10
+ Highlights: a Claude turn pauses at the usage limit and carries on after the
11
+ reset, a command that prints nothing no longer fails its turn, and agents on
12
+ paired relays can work with each other for a whole session.
13
+
14
+ ### Added
15
+
16
+ - The relay keeps a copy of its console in `relay-console.log` in its log
17
+ folder (`server/logs` unless you moved it), with a time stamp per line. The
18
+ file is rotated at 5 MB and three older copies are kept. The relay's token is
19
+ never written to it. Set `OAR_NO_CONSOLE_LOG=1` to switch it off.
20
+ - **Claude usage limit:** a Claude turn that runs into the subscription's
21
+ usage limit is paused instead of failed. Your message closes with a note,
22
+ the relay queues a message of its own ("continue where you left off") and
23
+ sends it a minute after the limit resets. A banner above the composer says
24
+ when, with **Resume now** and **Cancel**. The pause survives a relay
25
+ restart. A reset more than six hours away (a weekly limit) waits for you to
26
+ resume it.
27
+ - A banner warns in Claude conversations when the usage passes 90 % of a
28
+ limit, with the time of the reset, and can be hidden until the next one.
29
+
30
+ ### Changed
31
+
32
+ - **Remote relays:** agents on paired relays can now work with each other for
33
+ a whole session. A relay you mentioned stays in reach whatever prompts arrive
34
+ later, and a relay whose agent wrote to a conversation is open to it, so the
35
+ agent that was asked can answer, ask back or report later. Until now a prompt
36
+ from another relay's agent took the tool away for the rest of the turn, also
37
+ in a turn you had started yourself. A prompt that crossed two relays is still
38
+ not passed on to a third relay you did not unlock; reading and waiting are no
39
+ longer limited. After 30 prompts to other relays' agents without a message
40
+ from you, the next one asks you first.
41
+ - Subagents are folded to their header inside the running turn, so a turn with
42
+ many of them stays short to scroll. The header says how many steps the
43
+ subagent has taken and shows the latest one, as far as there is room. A tap
44
+ on the header unfolds a subagent and folds it again; what you chose by hand
45
+ stays. The Stop button of a finished subagent is gone.
46
+ - **Copilot, Cursor, Grok:** how long a turn may stay quiet now depends on what
47
+ the agent is doing: 2 minutes with nothing in flight, 5 minutes during a model
48
+ request, 30 minutes while a tool runs. Until now every silence of 2 minutes
49
+ failed the turn, including a command that simply printed nothing. The failure
50
+ note says how long the silence was and what was running. The three windows can
51
+ be set with `OAR_TURN_STALL_IDLE_MS`, `OAR_TURN_STALL_MODEL_MS` and
52
+ `OAR_TURN_STALL_TOOL_MS`.
53
+ - **Copilot:** a turn kept past 2 minutes of silence is checked: a runtime that
54
+ no longer answers fails the turn at once. The watchdog also covers the start of
55
+ a session, where a runtime that did not answer used to hold the message without
56
+ any failure, and a Stop during that start now takes effect at once.
57
+ - **Cursor:** a stalled run is cancelled, so the next message does not wait for
58
+ it.
59
+ - A plan board is part of the reply it belongs to: its content and its buttons
60
+ are shown inside the agent's reply bubble (inside the running turn's bubble
61
+ until the reply is there) instead of on a card of their own at the end of the
62
+ transcript. When the reply already is the plan, the plan is shown once, with
63
+ the buttons below it. After you chose, the plan stays and the bubble says
64
+ what was chosen.
65
+ - The choice on a plan board also sets the mode of the session: after
66
+ "Implement in autopilot" the composer says Autopilot, after "Stop here and
67
+ prompt myself" it says Agent, and the next message carries the work on. The
68
+ session used to stay in Plan, so the next message planned again. "Stop here"
69
+ leaves the mode as it is.
70
+
71
+ ### Fixed
72
+
73
+ - A session worker that was idle and whose process has ended (stopped from
74
+ outside, or crashed between turns) is no longer listed as ready with its old
75
+ process id. The next message started a new worker before as well; the list
76
+ now says what is there, about a minute and a half after the process ended.
77
+ - A reply that quotes or describes a failure note (an agent reporting on a
78
+ failed test, for example) is no longer taken for a failure itself. The relay
79
+ cut such a reply off at the quoted error code, put its own advice behind it
80
+ and stored the turn as failed. Where a failure is known from its text alone,
81
+ that text is now kept whole.
82
+ - A single `~` in a reply ("about", as in "~35 s … ~7 min") no longer strikes
83
+ out the text up to the next one. Strikethrough needs two tildes on each side:
84
+ `~~text~~`. The page now names the version of its Markdown library (15.0.12,
85
+ the one it was served until now) instead of taking whatever the CDN hands out.
86
+ - **Copilot:** a command the agent had started no longer runs on after its
87
+ runtime is gone. When the runtime died or was killed in the middle of a turn,
88
+ the turn failed as it should, but the command kept running with nobody left
89
+ to read its result. The relay now stops what such a runtime leaves behind
90
+ (first gently, then by force), and the failure note says that a command that
91
+ was still running is being stopped, as far as that is known at that moment:
92
+ the note comes at once and does not wait for the list of processes. A command
93
+ the agent started to outlive the session (`detach`) is left alone, and so are
94
+ the commands of a runtime that shuts down in good order.
95
+ - **Grok:** a quiet-turn window that is set for the model request or for a tool
96
+ is kept when the shared watchdog is switched off by the environment, and the
97
+ watchdog looks often enough for the shortest window that is set.
98
+ - **Windows:** stopping or deleting a session could end the worker of another
99
+ session as well. Windows hands the process id of an ended process out again,
100
+ and a worker whose creator was long gone could so look like a child of the
101
+ session that was being stopped. A process older than its parent is no longer
102
+ taken for its child, and the worker of another session never is.
103
+ - **Copilot:** a message sent while a command is running no longer cuts the
104
+ command short. It waits until the running tool call has ended and is steered
105
+ in then; the composer reads "Queue" for as long. Sent at once, the message
106
+ made the runtime push the command to the background: the turn ended early
107
+ with the answer to the new message alone, or failed with "No response was
108
+ returned", and the command's result arrived later in a turn of its own.
109
+ - A plan board's action ("Implement in autopilot" and the like) queues its
110
+ follow-up again. It failed with an error and left the board marked as acted
111
+ on, without the message that carries the plan out.
112
+ - A finished subagent no longer turns back to "Running" in the running turn
113
+ when another line of activity arrives or the page reloads.
114
+ - **Previews:** the first page load after a dev server was restarted no longer
115
+ ends in "The upstream connection failed: ECONNRESET". The preview lane keeps
116
+ its connections to the dev server, and the one it had kept was closed by the
117
+ restart. A request without a body is now sent once more on a connection of
118
+ its own: it reaches the restarted server, or the page says that nothing is
119
+ listening on the port.
120
+ - Links in the file viewer and in plan boards have a colour that can be read in
121
+ dark mode; they used the browser's default blue.
122
+ - A question or approval card no longer answers itself when the relay is
123
+ briefly unreachable, for example while it restarts. One failed check used to
124
+ end the wait: the agent was told to use its own judgement, or the approval
125
+ was refused, while the card stayed on screen. The wait now lasts through the
126
+ outage, for every provider.
127
+ - **Copilot:** a turn that fails is reported at once. The failure used to wait
128
+ for the runtime to finish stopping, which took three minutes on a runtime that
129
+ had stopped answering; stopping it is now bounded, and the next message waits
130
+ for it before a new runtime starts.
131
+ - **Copilot:** a failed turn keeps what the agent had written, above the failure
132
+ note. The note says how many tool calls ran and that their changes are still in
133
+ place, and it no longer advises restarting the relay. A message steered into
134
+ the failed turn gets a short marker with Resend instead of a second copy of
135
+ the failure.
136
+ - **Copilot:** a slow relay no longer makes a busy runtime look silent.
137
+ - **Copilot:** a reply the relay could not take (it was restarting, the
138
+ connection was reset) is offered again for up to five minutes. One failed
139
+ attempt used to put the message back in the queue, which threw the finished
140
+ answer away and ran the prompt a second time. A reply that cannot be saved at
141
+ all ends as a failure that says so, and nothing is run twice.
142
+ - **Copilot:** what the agent does by itself after a background task (a
143
+ continuation) is kept while the relay restarts. Its row was given up after
144
+ about a second, and the whole reply with it.
145
+ - **Copilot:** a relay that accepts live output and does not answer no longer
146
+ holds the turn: a live update waits 10 seconds at most, and after one that
147
+ ran out, live updates are skipped for 30 seconds. Tool lines skipped then are
148
+ missing from the transcript; the reply is not affected.
149
+ - **Copilot:** Stop works on a turn that is stuck. A runtime that does not
150
+ answer the Stop within 10 seconds is ended with the turn, and a turn the user
151
+ stopped ends as stopped even when the runtime died or went silent after it.
152
+ - **Copilot:** a runtime that exits, or stops answering, while the agent works
153
+ by itself after a background task is cleared away; the next message used to
154
+ fail on it. A lost connection during a model change is reported as that, not
155
+ as "pick another model".
156
+ - **Copilot:** a failed turn tells its failure once. A message sent into the
157
+ turn that the agent had not started on gets the short marker with Resend,
158
+ like one it had taken in.
159
+ - **Copilot:** background agents and shells are no longer closed with the
160
+ runtime because its list of them could not be read.
161
+ - **Stop button:** Stop keeps working for a message that waits on a question
162
+ card while the agent starts and finishes other work, and for a second message
163
+ stopped while the same turn goes on. It used to do nothing in both cases.
164
+ - **Copilot:** a turn the runtime itself ended with an error no longer tells
165
+ you to restart the relay. The worker starts a fresh runtime by itself; a
166
+ restart repairs nothing and costs every other running session.
167
+ - The note under a failed turn no longer advises restarting the relay: a restart
168
+ repairs nothing there and interrupts every other running session. It now says
169
+ to send the message again and to include the error code in a report.
170
+ - **Claude:** a turn the Claude account refuses (billing, an expired sign-in,
171
+ access the organisation has switched off) says to check the account or to sign
172
+ in again in Settings → Providers → Claude, with a button that opens the panel,
173
+ instead of "Retry the message". Its error code names the failure, for example
174
+ `relay.claude-billing-error`; it used to read `relay.claude-success`.
175
+
8
176
  ## [0.9.5] — 2026-09-27
9
177
 
10
178
  Highlights: a Claude reply on the wrong row no longer blocks the conversation,
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.5 sh
81
+ curl -fsSL oar.sh/install | OAR_VERSION=0.9.6 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.5'` 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.6'` before the command.
93
93
 
94
94
  ### npm
95
95
 
@@ -326,6 +326,15 @@ 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.6
330
+
331
+ - **Claude pauses at the usage limit**: a turn that runs into the subscription's usage limit is paused instead of failed. A banner above the composer says when it carries on, with **Resume now** and **Cancel**; after the reset the relay sends a message of its own that quotes what was refused. The pause survives a relay restart, and a reset more than six hours away waits for you. A second banner warns when the usage passes 90 % of a limit.
332
+ - **A quiet command no longer fails its turn** (Copilot, Cursor, Grok): how long a turn may stay silent depends on what the agent is doing: 2 minutes with nothing in flight, 5 minutes during a model request, 30 minutes while a tool runs.
333
+ - **Copilot turns that fail, fail well**: the failure shows at once, what the agent had written stays above the note, Stop works on a stuck turn, a finished reply survives a relay restart, and the commands of a runtime that died are stopped. A message you send while a command runs waits for the command instead of cutting it short.
334
+ - **Agents on paired relays can answer each other**: a relay you mentioned stays in reach for the whole session, and an agent that was asked from another relay can answer, ask back or report later. See [Remote relays](#remote-relays).
335
+ - **Less scrolling in a running turn**: subagents are folded to a one-line header that says what they are doing, and a plan board sits inside the reply it belongs to; its choice also sets the session's mode.
336
+ - **Failure notes that help**: no more advice to restart the relay, and a Claude account problem (billing, sign-in, access) says what to check. The relay keeps its console in `relay-console.log`.
337
+
329
338
  ### New in 0.9.5
330
339
 
331
340
  - **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.
@@ -677,8 +686,15 @@ locked in a conversation until you mention it in one of your messages: type `@`
677
686
  from the list, or write its name or its host name. An IP address or `localhost` never counts as a
678
687
  host name; a relay whose name is itself an address is unlocked only with a leading `@`. The
679
688
  mention unlocks that relay for the rest of
680
- the conversation. Prompts that arrive from another relay's agent never unlock anything, and a
681
- chain of relays prompting relays stops after two hops.
689
+ the conversation, whatever prompts arrive in it later.
690
+
691
+ **Agents can answer each other.** When an agent on a paired relay writes to a conversation, that
692
+ relay is open to the conversation from then on, within what **Agents may** allows, so the agent
693
+ that was asked can answer, ask back or report later. A prompt from an agent never unlocks any
694
+ other relay. A prompt that already crossed two relays is not passed on to a third one unless you
695
+ unlocked that relay yourself; reading and waiting are never limited. When the agent of a
696
+ conversation has sent 30 prompts to agents on other relays without a message from you, the next
697
+ one asks you first with a question card; **Allow** starts the count again.
682
698
 
683
699
  **What agents can do.** Every provider gets the same `remote_relay` tool (Claude, Cursor and
684
700
  Copilot directly; Grok and the Copilot extension engine through OAR's MCP server). Per paired
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  "server/uploads/image_input.md",
31
31
  "shared/",
32
32
  "!shared/**/*.test.mjs",
33
+ "!shared/**/*-test-harness.mjs",
33
34
  "!shared/**/fixtures/",
34
35
  ".github/extensions/web-relay/",
35
36
  "!.github/extensions/web-relay/**/*.test.mjs",
@@ -37,6 +37,7 @@ 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 { createTurnLiveness, resolveTurnStallWindows } from '../../shared/worker-runtime/turn-liveness.mjs';
40
41
  import {
41
42
  ANSWERED_ELSEWHERE_KIND,
42
43
  DELIVERY_ANSWERED_ELSEWHERE_TEXT,
@@ -49,6 +50,7 @@ import {
49
50
  stripSlashCommandGuardContent,
50
51
  withSlashCommandGuardContent,
51
52
  } from '../../shared/slash-command-guard.mjs';
53
+ import { createUsageLimitTracker } from '../../shared/claude-usage-limit.mjs';
52
54
 
53
55
  /**
54
56
  * Which system-prompt append a relay mode gets (claude-sdk-adapter's
@@ -451,6 +453,10 @@ export function createClaudeSessionRunner({
451
453
  // next delivery.
452
454
  getThinking = () => ({ enabled: null, display: '' }),
453
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,
454
460
  // A settled task's continuation normally begins within ~1s; when nothing
455
461
  // arrives inside this window the notification was silent (skip_transcript)
456
462
  // and must stop pinning the process.
@@ -546,6 +552,9 @@ export function createClaudeSessionRunner({
546
552
  // fixed per process, and the first delivery can beat the startup GET.
547
553
  const remoteRelayGate = remoteRelayToolGate || createRemoteRelayToolGate({ api });
548
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();
549
558
  const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
550
559
  let claudeNativeSessionId = '';
551
560
  let proc = null;
@@ -2475,6 +2484,90 @@ export function createClaudeSessionRunner({
2475
2484
  return 'swallow';
2476
2485
  }
2477
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
+
2478
2571
  async function onSdkMessage(processRef, sdkMessage) {
2479
2572
  // A superseded process (push-race respawn, mode-recycle timeout) may still
2480
2573
  // be draining its stream; its late messages must not mutate the state of
@@ -2482,6 +2575,21 @@ export function createClaudeSessionRunner({
2482
2575
  // cleanupProcess when the old stream ends.
2483
2576
  if (!proc || proc !== processRef) return;
2484
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 });
2485
2593
  const suppressed = maybeSuppressStopFollowUp(sdkMessage);
2486
2594
  if (suppressed === 'swallow') return;
2487
2595
  observeProcessLevel(sdkMessage);
@@ -2502,6 +2610,8 @@ export function createClaudeSessionRunner({
2502
2610
  if (!ctx) return;
2503
2611
  const actions = ctx.normalizer.normalize(sdkMessage);
2504
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);
2505
2615
  await dispatchToContext(ctx, action);
2506
2616
  }
2507
2617
  }
@@ -2917,6 +3027,7 @@ export function createClaudeSessionRunner({
2917
3027
  if (proc.stopFollowUp && !proc.suppressingTurn && !proc.pendingDelivered.some((entry) => entry.stoppedWithTurn)) {
2918
3028
  proc.stopFollowUp = null;
2919
3029
  }
3030
+ noteQuietTurn();
2920
3031
  reapSilentProvisionalAdoption(now);
2921
3032
  // Before the 5-minute watchdog: a folded steered entry settles as merged
2922
3033
  // within the short fold grace instead of failing over.
@@ -3030,6 +3141,7 @@ export function createClaudeSessionRunner({
3030
3141
  await publisher.publishTurnException({
3031
3142
  message: ctx.message,
3032
3143
  errorText: String(streamError?.message || streamError || 'unknown error'),
3144
+ usageLimit: usageLimit.classifyException(streamError),
3033
3145
  }).catch(() => {});
3034
3146
  }
3035
3147
  continue;
@@ -3132,6 +3244,11 @@ export function createClaudeSessionRunner({
3132
3244
  settleSeqAtContinuationOpen: 0,
3133
3245
  pendingControlRequests: 0,
3134
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,
3135
3252
  heldForTasksSince: 0,
3136
3253
  taskSettledAt: 0,
3137
3254
  // Bumped by every task-settle signal; the Stop guard compares it.
@@ -3631,7 +3748,7 @@ export function createClaudeSessionRunner({
3631
3748
  settingsWindowOpen = false;
3632
3749
  const errorText = String(error?.message || error || 'unknown error');
3633
3750
  dbg('claude turn failed', message.id, errorText);
3634
- await publisher.publishTurnException({ message, errorText });
3751
+ await publisher.publishTurnException({ message, errorText, usageLimit: usageLimit.classifyException(error) });
3635
3752
  return true;
3636
3753
  } finally {
3637
3754
  endAdmission();
@@ -0,0 +1,92 @@
1
+ // Why a Claude turn failed: the code the relay prints, and the advice that
2
+ // fits the failure.
3
+ //
4
+ // A turn the API refused ends with a `result` of subtype 'success' and
5
+ // `is_error: true`: the subtype says how the turn ended, not whether it
6
+ // worked, so it names nothing. What names the failure, in this order:
7
+ //
8
+ // 1. the `error` field of the turn's last assistant message (the SDK's
9
+ // SDKAssistantMessageError: 'billing_error', 'oauth_org_not_allowed', …);
10
+ // 2. the `api_error_status` of the result;
11
+ // 3. the words of the refusal, for an account failure that came with
12
+ // neither;
13
+ // 4. a subtype other than 'success' ('error_max_turns', …);
14
+ // 5. the `terminal_reason` of the result.
15
+ //
16
+ // The relay prints `claude.<code>` as `relay.claude-<code>`, dashes for
17
+ // underscores.
18
+
19
+ // The user has to sign in again: a retry cannot succeed before that.
20
+ const SIGN_IN_ERRORS = new Set(['authentication_failed']);
21
+ // The account itself refuses: billing, or access its organisation withholds.
22
+ const ACCOUNT_ERRORS = new Set([
23
+ 'billing_error',
24
+ 'oauth_org_not_allowed',
25
+ 'account_on_hold',
26
+ 'verification_required',
27
+ 'account-access',
28
+ ]);
29
+
30
+ const STATUS_CODES = new Map([
31
+ [401, 'authentication_failed'],
32
+ [402, 'billing_error'],
33
+ [429, 'rate_limit'],
34
+ [529, 'overloaded'],
35
+ ]);
36
+
37
+ // Checked before the sign-in words: the organisation's refusal mentions an
38
+ // API key too.
39
+ const ACCOUNT_ACCESS_TEXT = /organi[sz]ation has disabled|subscription access|does not have access to claude/i;
40
+ const BILLING_TEXT = /credit balance|billing|payment/i;
41
+ const SIGN_IN_TEXT = /failed to authenticate|authentication_error|invalid api key|not logged in|please run \/login/i;
42
+
43
+ export const CLAUDE_SIGN_IN_GUIDANCE =
44
+ 'Sign in to Claude again in Settings → Providers → Claude → Relogin (or run `claude` on the relay host), '
45
+ + 'then send the message again.';
46
+ export const CLAUDE_ACCOUNT_GUIDANCE =
47
+ 'Check the Claude account the relay host is signed in to: its subscription, its payment method, and whether '
48
+ + 'its organisation allows Claude Code. Settings → Providers → Claude shows the account and signs in to '
49
+ + 'another one. Then send the message again.';
50
+ export const CLAUDE_MODEL_GUIDANCE = 'Choose another model for this conversation, then send the message again.';
51
+
52
+ function codeFromText(text) {
53
+ if (ACCOUNT_ACCESS_TEXT.test(text)) return 'account-access';
54
+ if (BILLING_TEXT.test(text)) return 'billing_error';
55
+ if (SIGN_IN_TEXT.test(text)) return 'authentication_failed';
56
+ return null;
57
+ }
58
+
59
+ /** The advice for a code, or null where the relay's own default fits. */
60
+ export function claudeFailureGuidance(code) {
61
+ const token = String(code || '').trim().toLowerCase();
62
+ if (SIGN_IN_ERRORS.has(token)) return CLAUDE_SIGN_IN_GUIDANCE;
63
+ if (ACCOUNT_ERRORS.has(token)) return CLAUDE_ACCOUNT_GUIDANCE;
64
+ if (token === 'model_not_found') return CLAUDE_MODEL_GUIDANCE;
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * `result` is the normalized result payload of the turn (sdk-message-
70
+ * normalizer): `{ text, subtype, assistantError, apiErrorStatus,
71
+ * terminalReason }`. Returns `{ code, stableCode, guidance }`; `guidance` is
72
+ * null where the relay's default fits.
73
+ */
74
+ export function classifyClaudeResultFailure(result = {}) {
75
+ const subtype = String(result?.subtype || '').trim();
76
+ const assistantError = String(result?.assistantError || '').trim().toLowerCase();
77
+ const status = Number(result?.apiErrorStatus);
78
+ const hasStatus = Number.isFinite(status) && status >= 400;
79
+ const terminalReason = String(result?.terminalReason || '').trim().toLowerCase();
80
+
81
+ let code = null;
82
+ if (assistantError && assistantError !== 'unknown') code = assistantError;
83
+ else if (hasStatus && STATUS_CODES.has(status)) code = STATUS_CODES.get(status);
84
+ else code = codeFromText(String(result?.text || ''));
85
+
86
+ if (!code && subtype && subtype !== 'success') code = subtype;
87
+ if (!code && (hasStatus || assistantError === 'unknown' || terminalReason === 'api_error')) code = 'api-error';
88
+ if (!code && terminalReason && terminalReason !== 'completed') code = terminalReason;
89
+ if (!code) code = 'turn-error';
90
+
91
+ return { code, stableCode: `claude.${code}`, guidance: claudeFailureGuidance(code) };
92
+ }