@oar-sh/oar 0.9.11 → 0.9.13

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 (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
package/server/README.md CHANGED
@@ -216,7 +216,8 @@ reports both the actual suspend and such a drop. For the first 45 seconds after
216
216
  a relay start the check lists one more blocker, "the relay just started,
217
217
  sessions are still reporting": the workers that outlived the previous runtime
218
218
  may not have reported their background work yet. A queued **🌄 Restart web relay**
219
- gets the same banner and can be cancelled from it too.
219
+ gets a banner of its own, with the sessions it waits for, **Restart now** and
220
+ **Cancel restart** (see [Manual relay shutdown / self-restart](#manual-relay-shutdown--self-restart)).
220
221
 
221
222
  API: `GET /api/host/suspend` (state plus current blockers), `POST /api/host/suspend`,
222
223
  `POST /api/host/suspend/cancel`, and `POST /api/relay/shutdown/cancel`
@@ -254,6 +255,47 @@ Behavior notes:
254
255
  - In extension-managed mode, the relay still switches model per message and
255
256
  reports the active model used in the response.
256
257
 
258
+ ### A message without a model, an effort or a mode
259
+
260
+ The composer always sends all three. A client that leaves one out of
261
+ `POST /api/message` (a script, an agent on this relay) gets the conversation's
262
+ own, never the relay-wide default:
263
+
264
+ - **Model**, for an existing Copilot conversation: the conversation's saved
265
+ `preferred_model`, then the runtime session's `provider_model`, then its
266
+ `model`, then the model of the latest user message (`auto` skipped in each);
267
+ only a conversation with none of these runs on the catalog's current model.
268
+ A saved model the catalog no longer lists is refused with
269
+ `409 CONVERSATION_MODEL_UNAVAILABLE` (`model`, `supportedModels`) instead of
270
+ being replaced; the composer shows that text in its banner. An explicit
271
+ `auto` is unchanged (new conversations only). Claude, Cursor, Grok, Claude
272
+ Cloud and OpenAI conversations keep their pinned `provider_model`, as before.
273
+ - **Effort**: for Copilot, an effort the model's variant id carries, else the
274
+ conversation's `preferred_reasoning_effort`, clamped to the nearest level the
275
+ model offers (`nearestSupportedReasoningEffort`, the composer's rule); with
276
+ nothing to keep, the model's lowest level that is not `none`, so `none` is
277
+ chosen only when the conversation asked for it or the model has no other
278
+ level. OpenAI, Claude, Cursor and Grok conversations take the conversation's
279
+ effort the same way; with no effort stored, or one that neither matches nor
280
+ clamps (an `ultracode` on a model without it), they keep the provider's own
281
+ default as before (`none`, which for Claude, Cursor and Grok means the
282
+ model's default). A Claude Cloud conversation always runs with `none`: the
283
+ cloud takes no effort. A board action's follow-up takes the conversation's
284
+ effort as well.
285
+ - **Mode**: a send that names no `relayMode`/`mode` runs in the
286
+ conversation's saved `preferred_relay_mode`, not the relay default.
287
+ - **Preferences**: a send to an existing conversation saves only what it named.
288
+ A model it named is saved (with its effort only when that was named too, else
289
+ the stored effort stays); an effort alone is saved against the stored model,
290
+ and a conversation with no saved model runs with that effort but saves
291
+ nothing; a send that named neither writes nothing. The mode saved with them
292
+ is the one the turn runs in, so a send without a mode leaves the stored mode
293
+ as it is. A send from another relay's agent never writes them, as before.
294
+
295
+ A text repeat inside the duplicate window answers `200` with `duplicate: true`
296
+ and `queued: false`, and nothing runs. A message whose turn **failed** no
297
+ longer counts: sending it again queues it.
298
+
257
299
  ### OpenAI API key
258
300
 
259
301
  Open **Settings → OpenAI API key** to save an OpenAI key and exact model ID
@@ -375,10 +417,14 @@ cached in the worker after the server accepts it, so a failed persist is retried
375
417
  The worker keeps one persistent CLI process per conversation, so background tasks survive the reply
376
418
  that started them. The live task set is posted to `POST /api/background-tasks` (REPLACE semantics)
377
419
  and broadcast as the `background_tasks` socket event; the composer's task panel renders it and can
378
- stop an individual task over a `worker.control` push.
420
+ stop an individual task over a `worker.control` push. The conversation payload carries the set too
421
+ (`backgroundTasks`, `endedBackgroundTasks`); the page's live poll and refresh leave it out when a
422
+ `background_tasks` event for the conversation arrived while their request was on its way, as the
423
+ event's set is at least as new.
379
424
 
380
425
  The relay holds the task sets in memory (`services/background-task-store.mjs`), so a relay restart
381
- starts without them while the workers and their tasks keep running. The worker is the source of
426
+ starts without them while the workers and their tasks keep running (the console rows are stored;
427
+ see below). The worker is the source of
382
428
  truth, and the relay asks:
383
429
 
384
430
  - **Digest and resync.** A worker's heartbeat carries `backgroundTasks: { count, digest }`, a digest
@@ -407,6 +453,43 @@ truth, and the relay asks:
407
453
  - **Ends.** The Claude worker keeps a settle notice whose post failed (the newest 20, for 10
408
454
  minutes) and sends it again after the next heartbeat the relay answers; such a post is given up
409
455
  after 10 s without an answer.
456
+ - **The console rows.** What the task console needs of a shell or Monitor task (its `outputPath`
457
+ and `outputKind`, the ended row with status, exit code and `endedAt`, how a task ended when the
458
+ settle came before the set dropped it) is kept in `background_task_rows` (migration 0018,
459
+ `services/background-task-persistence.mjs`), written only when that part of a set changes, not on
460
+ every progress post. At boot the store restores the ended rows still inside their hour, and holds
461
+ the live ones (`restored`): the output route and the console's live room find a held task (the
462
+ route answers it with `status: 'held'`, `held: true` and `stoppable: false`; the console says it
463
+ waits for its worker), but `get()` and with it the panel, the session state and the suspend check
464
+ do not count it as running, and the stop route only pushes the stop to the worker. The worker's next set settles it (a task in the set is live again, with the
465
+ path it had; one not in it becomes an ended row, with the status the runtime before was told), a
466
+ settle sent again for it ends it, and while a key has held tasks its heartbeat digest never
467
+ counts as in step, so the relay asks for the set. Tasks of a worker that sends no set within twice
468
+ the boot reporting window (at least 15 s; 90 s by default) are ended with status `unknown` and the
469
+ pages get the `background_tasks` event (`endUnconfirmed`); a later set that lists one brings it
470
+ back. A held task's end time is when the runtime before last knew it running: a stored live row's
471
+ `updated_at` is written with the set and confirmed at most every 30 minutes while the worker's
472
+ digest agrees, and a live row not confirmed for 6 hours (a worker that went away in an earlier
473
+ runtime without an empty set) is deleted at boot instead of restored. Health readings are not
474
+ stored: the poller reads the files again within 10 s, the ended rows once. A runner's counts come
475
+ from the last 64 KB when its closing summary is there. A live log that outgrew that window is read
476
+ whole by a per-task runner scan (`services/task-output-service.mjs`): it looks for a runner's
477
+ header (a test run after a long install or build prints its header once) and counts from there,
478
+ reads on from where it stopped as the file grows, and starts over on a new file under the path
479
+ (shorter, another inode, an older mtime). The scans live in memory and share 4 MB per tick, each
480
+ tick starting at another task, so after a restart a long log is caught up over a few ticks (the
481
+ window's reading stands until then) and one busy log cannot keep the others waiting. An ended row
482
+ never starts a scan, so the rows a restart brings back are read in their window only. What a
483
+ worker says about a task (description, summary, last tool call, and a shell row's `command`, which
484
+ the Claude worker takes from the Bash or Monitor call that started the task, Copilot from the
485
+ shell tool's arguments or the task registry, Grok from the terminal) goes through `cleanTaskText`
486
+ on the publish and settle routes: control codes and colours out, credentials scrubbed as in the
487
+ output, then cut (a command at 2,000 characters; a Grok description is its command line). It is
488
+ stored with the console rows, so a held or ended row keeps it; share links carry no task rows. The
489
+ `none` answers of the output route use the page's texts (`TASK_OUTPUT_NONE_MESSAGES` in
490
+ `public/app/task-console-render.mjs`). Rows go with their hour, the 20-per-key limit, a set that
491
+ drops them or the conversation's delete. Without the table (a failed migration) the store works in
492
+ memory as before.
410
493
  - **Run rows left `running`.** A background sub-agent's row whose end was never written would count
411
494
  as running work for a day. For a Claude worker that has reported its task set in this runtime, a
412
495
  `running` background row that the set does not list and that nothing has updated for 15 minutes
@@ -773,10 +856,22 @@ be on, as for every local action; a paired relay: see [Across paired relays](#ac
773
856
  **Events** (`shared/relay-events.mjs`): `question_asked` (prompt, options, question id; an approval
774
857
  card is marked as one only the user can answer), `error` (`turn_failed`, `usage_limit`,
775
858
  `worker_died`), `turn_end` (status `done` or `stopped`, reply preview), `task_finished` /
776
- `task_failed` (background sub-agents and shell tasks: kind, status, exit code where known),
777
- `stalled` (no activity for `stalled_minutes`, default 15, while a turn or task runs and no
778
- question waits for an answer), `all_idle`
779
- (nothing runs in the watched set any more). `task_started` only when named in `events`. With
859
+ `task_failed` (background sub-agents and shell tasks: kind, status, exit code where known; a
860
+ non-zero exit is `task_failed`), `stalled` (no activity for `stalled_minutes`, default 15, while a
861
+ turn or task runs and no question waits for an answer), `all_idle`
862
+ (nothing runs in the watched set any more). `task_started` only when named in `events`.
863
+ `subagent_finished` is no event of its own but a narrower `task_finished` a subscription can name
864
+ (`RELAY_EVENT_SUBSCRIBABLE_TYPES`): the end of a task that is not a shell command, delivered as a
865
+ `task_finished` line. The default (`events` left out) is every type but `task_started` and
866
+ `task_finished`, with `subagent_finished`: a shell command that ended well wakes only a watcher
867
+ that names `task_finished`, which takes every task end. A busy worker ends a shell command every
868
+ minute or so, and each was a wake-up, a model turn on the watcher's whole context. A subscription
869
+ whose stored list is exactly the old default (every type but `task_started`, in any order) named no
870
+ events and is read as the new default (`effectiveRelayEventTypes`, at read time, the row is never
871
+ rewritten, so an older relay reading the same database sees what it stored); any other list,
872
+ including the old default plus more, is the watcher's choice and stays. A subscribe that names
873
+ exactly the old default list stores `subagent_finished` with it, so it is never read as the default. A mirror shows the paired
874
+ relay's list as that relay returned it. With
780
875
  `heartbeat_minutes` a `heartbeat` event comes at that interval while the watched set has work
781
876
  running, none while it is idle. `match` (a case-insensitive regular expression) filters `turn_end`
782
877
  and task events by their text; other events pass. `once` ends a subscription after its first event,
@@ -899,7 +994,14 @@ turn processing, or queued and not held by a usage-limit pause; `background_only
899
994
  `subagent` | `shell`, `description`, `startedAt`, `lastActivityAt`, `status` `running` |
900
995
  `completed` | `failed` | `stopped`, `exitCode`, `summary`, `endedAt`) and `lastActivityAt`.
901
996
  `remote_relay`'s `list_sessions` and `read_session` pass them on as `state`, `tasks` and
902
- `lastActivityAt`; `active` stays and is true for background work too.
997
+ `lastActivityAt`; `active` stays and is true for background work too. `read_session` adds to each
998
+ task its `taskId`, `output` (whether it has output to read), a shell task's `command` (its command
999
+ line where the worker reports one, scrubbed of credentials and cut at 2,000 characters by the relay
1000
+ when the worker posts it, shown to at most 500) and, for a task with an output file, `health`
1001
+ (`minutesSinceOutput`, `quiet`, `quietAfterMinutes`, test-run `counts`);
1002
+ `read_task_output {session, task_id, lines?}` (read permission; default 200 lines, at most 500 or
1003
+ 32 KB) reads the end of that output through `GET /api/conversation/:id/background-task/:taskId/output`,
1004
+ scrubbed of credentials.
903
1005
 
904
1006
  ### Browser
905
1007
 
@@ -1158,6 +1260,7 @@ After launching the server, the CLI opens a worker WebSocket link:
1158
1260
  - Identifies itself with the bound SDK session id and worker pid
1159
1261
  - Receives queued turns over `ws(s)://.../api/session-worker/ws`
1160
1262
  - Reconnects starting at 1 second, with exponential backoff capped at 8 seconds
1263
+ (see [Worker link reconnects](#worker-link-reconnects))
1161
1264
  - Falls back to `GET /api/pending` only while the worker socket is unavailable
1162
1265
  - Processes any message with full PC access (file system, commands, etc.)
1163
1266
  - Posts the response back
@@ -1172,6 +1275,33 @@ After launching the server, the CLI opens a worker WebSocket link:
1172
1275
  - Answered relay question cards stay visible in the conversation journal, including
1173
1276
  the answer you submitted.
1174
1277
 
1278
+ ### Worker link reconnects
1279
+
1280
+ Every session worker (Copilot SDK, Claude, Claude Cloud, Cursor, Grok) and the Copilot CLI extension
1281
+ use the same link, `shared/worker-runtime/worker-websocket-link.mjs`. Its reconnect rules:
1282
+
1283
+ - A connection counts as made at the relay's first `server.hello`, which the relay sends as soon as it
1284
+ accepts the socket. A socket still connecting, or open without that hello, after 15 seconds
1285
+ (`connectTimeoutMs`) is given up and retried: a relay hung in its shutdown can hold a socket in
1286
+ either state without ever ending it.
1287
+ - The link ends a socket on whichever comes first of an error, a close, that deadline, a stale
1288
+ connection (30 seconds without a server frame) and the relay's `server.draining`, and schedules
1289
+ the next attempt from there; later events from the same socket are ignored. It never waits for a
1290
+ close event, which Node 22 does not fire for a socket closed while connecting, and a socket whose
1291
+ constructor throws is retried too.
1292
+ - There is at most one pending reconnect and one socket at a time. The delay starts at 1 second and
1293
+ doubles up to 8 seconds, plus up to 250 ms of jitter; it starts over only after a completed
1294
+ handshake, so a relay that accepts and drops the socket is not retried every second.
1295
+ - Each failed attempt logs one `worker ws disconnected <reason> attempt=<n> retryInMs=<ms>` line in
1296
+ the worker log for the first three, then at most one a minute, with `notLogged=<n>` counting the
1297
+ ones in between.
1298
+ - `stop()` (worker shutdown) clears the pending reconnect and the deadline and closes the socket
1299
+ without scheduling another attempt.
1300
+
1301
+ The relay's detached-worker watch (`services/detached-worker-watch-service.mjs`) is the safety net
1302
+ behind this: it replaces a worker that has had no socket for 60 seconds while a message waits, but
1303
+ never while a turn or background work of that session runs, so the link has to reconnect by itself.
1304
+
1175
1305
  ### Session activation behavior (important)
1176
1306
 
1177
1307
  In extension-managed mode, the web server can already be running while the CLI is still
@@ -1285,6 +1415,9 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
1285
1415
  | POST | `/api/relay/pause` | Pause dequeueing and drop currently queued messages |
1286
1416
  | POST | `/api/relay/resume` | Resume dequeueing after pause |
1287
1417
  | POST | `/api/relay/shutdown` | Queue an authenticated relay shutdown or self-restart |
1418
+ | GET | `/api/relay/shutdown` | The queued request's state (as `relay_shutdown_state`), plus `preview`: what a restart asked now would wait for and leave running (`?action=shutdown` for a shutdown). See [Manual relay shutdown / self-restart](#manual-relay-shutdown--self-restart) |
1419
+ | POST | `/api/relay/shutdown/keep-waiting` | Answer to the max-wait question: the queued request waits another full period, counted from now → `{ ok, extended, …state }` |
1420
+ | POST | `/api/relay/shutdown/cancel` | Withdraw a queued (not yet started) shutdown or restart; held messages go out at once |
1288
1421
  | GET | `/api/relay-questions` | (CLI/UI) List relay questions by `status` (for example `pending` or `answered`) |
1289
1422
  | GET | `/api/relay-question/:id` | (CLI) Fetch a single relay question |
1290
1423
  | POST | `/api/relay-question` | (CLI) Create a relay question for the browser |
@@ -1301,14 +1434,16 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
1301
1434
  | GET | `/api/usage` | Unified plan usage: one card per configured provider (`providers[]` with `meters`, `details`, `links`) built from the live Copilot quota, the Claude worker's latest stored snapshot, and the derived Cursor cycle totals. A provider that cannot be read yields an unavailable card rather than failing the response, so the modal always renders. While Claude Cloud is on, the Claude card's windows are read live from the account, and a Claude Cloud card is added (credit meters, the relay's cloud spend, and the cost of the conversation named by `?conversationId=` when it is a cloud one); a card may carry `notes[]` (a line of text with an optional https link), and a meter `severity` and `resetKind: 'expiry'`. Legacy top-level Copilot quota fields are spread alongside `providers` for cached clients; `?legacy=1` returns the Copilot-only snapshot on its own |
1302
1435
  | GET | `/api/settings/cursor-allowance` | Read the manual Cursor plan allowances (`cursorModelsUsd`, `otherModelsUsd`, `resetDay`) |
1303
1436
  | POST | `/api/settings/cursor-allowance` | Set the Cursor pool allowances and billing reset day; `null` clears an allowance, and `resetAccounting: true` re-baselines the derived spend tracking |
1304
- | GET | `/api/settings/claude` | Read Claude provider settings (`enabled`, `model`, `models`, `availableModels`) |
1305
- | POST | `/api/settings/claude` | Enable/disable Claude, set the default model or the enabled model subset; triggers discovery and unstarted-conversation reconciliation |
1437
+ | GET | `/api/settings/claude` | Read Claude provider settings (`enabled`, `model`, `models`, `availableModels`, `attributionMode`, `backgroundCommandLimitMinutes` with the slider bounds `backgroundCommandLimitMinMinutes`, `backgroundCommandLimitMaxMinutes`, `backgroundCommandLimitStepMinutes`, `backgroundCommandLimitDefaultMinutes`) |
1438
+ | POST | `/api/settings/claude` | Enable/disable Claude, set the default model, the enabled model subset, the attribution mode or `backgroundCommandLimitMinutes` (clamped to 30…480 and snapped to 30-minute steps; not a number is `400`; read by Claude sessions spawned afterwards); triggers discovery and unstarted-conversation reconciliation |
1306
1439
  | GET | `/api/settings/claude-cloud` | Read Claude Cloud settings: `{ enabled, defaultModel, environmentId, environments: [{ id, name }] \| null, environmentsError, account: { loggedIn, email, orgName, subscriptionType } \| null, token: { source, hasToken, expiresAt }, models }`. `token` says where the login is read from (`env`, `file`, `none`) and when it runs out, never the token. `environments` is `null` while the provider is off or the host has no login: nothing is asked of Anthropic then |
1307
1440
  | POST | `/api/settings/claude-cloud` | `{ enabled?, defaultModel?, environmentId? }` → `{ ok: true, …the GET body }`. Enabling without a Claude login on the host is `400` with `code: 'claude_cloud_login_missing'`; an empty `environmentId` clears the stored one. Emits `claude_cloud_settings_updated` |
1308
1441
  | GET | `/api/settings/cursor` | Read Cursor provider settings — same shape as Claude's: `models` is the enabled subset, `availableModels` the full discovered list, plus per-model `efforts` |
1309
1442
  | POST | `/api/settings/cursor` | Set/remove the Cursor API key, default model, or enabled model subset; key changes reset discovered models and effort tiers |
1310
1443
  | GET | `/api/settings/turn-ceiling` | Read the max turn duration plus slider bounds (`minMinutes`, `maxMinutes`, `stepMinutes`, `defaultMinutes`) |
1311
1444
  | POST | `/api/settings/turn-ceiling` | Set the max turn duration in minutes (`0` = no limit) |
1445
+ | GET | `/api/settings/relay-restart-max-wait` | How long a queued relay restart waits before it asks: `{ maxWaitMinutes, choices: [5, 10, 15, 30, 60, 120, 240, 0], defaultMinutes: 30, overrideMs }`; `0` = no limit; `overrideMs` is `OAR_RELAY_RESTART_MAX_WAIT_MS` when set |
1446
+ | POST | `/api/settings/relay-restart-max-wait` | `{ maxWaitMinutes }` → the GET body; anything but one of `choices` is `400`. A queued request measures its current period against the new value at once |
1312
1447
  | GET | `/api/settings/subagent-record-retention` | How long sub-agent records keep tool inputs and output: `{ retentionDays, choices: [0, 7, 14, 30, 90], defaultDays: 90 }`; `0` = keep for good |
1313
1448
  | POST | `/api/settings/subagent-record-retention` | `{ retentionDays }` → the GET body; anything but one of `choices` is `400`. Runs a retention sweep 5 s later |
1314
1449
  | POST | `/api/claude-native-session` | (Claude worker) Persist the native Agent SDK session id for resume |
@@ -1341,7 +1476,7 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
1341
1476
  | POST | `/api/remote-relays/:id/check` | Probe a paired relay now |
1342
1477
  | POST | `/api/remote-relays/pair` | (Relay to relay) Another relay introduces itself after the user paired it there |
1343
1478
  | GET | `/api/remote-relays/summary` | (Worker) `{ count, online, localEnabled }`: the number of paired relays, how many of them are online, and whether agent sessions are switched on. Workers register `remote_relay` when `count` is above zero or `localEnabled` is `true` |
1344
- | POST | `/api/remote-relays/tool` | (Worker) One `remote_relay` tool call: `{ conversationId, action, args }`. For a paired relay the relay checks the mention unlock, the permission, the hop limit and (ask/plan mode) the user's approval, then forwards the call to the remote. For this relay itself (named by its name or `this`; refused with `REMOTE_RELAY_LOCAL_DISABLED` while agent sessions are off) it checks instead that the call does not aim at the caller's own conversation (`REMOTE_RELAY_OWN_SESSION`) and, for `create_session`, that the caller was not created by an agent (`REMOTE_RELAY_NESTED_SESSION`), that fewer than 4 sessions it created have a turn queued or running (`REMOTE_RELAY_SESSION_LIMIT`) and that the user allowed it once for the conversation; the call then runs against this relay's own routes in-process. `wait_seconds` is cut to the `maxWaitSeconds` setting for every target |
1479
+ | POST | `/api/remote-relays/tool` | (Worker) One `remote_relay` tool call: `{ conversationId, action, args }`. For a paired relay the relay checks the mention unlock, the permission, the hop limit and (ask/plan mode) the user's approval, then forwards the call to the remote. For this relay itself (named by its name or `this`; refused with `REMOTE_RELAY_LOCAL_DISABLED` while agent sessions are off) it checks instead that the call does not aim at the caller's own conversation (`REMOTE_RELAY_OWN_SESSION`) and, for `create_session`, that the caller was not created by an agent (`REMOTE_RELAY_NESTED_SESSION`), that fewer than 4 sessions it created have a turn queued or running (`REMOTE_RELAY_SESSION_LIMIT`) and that the user allowed it once for the conversation; the call then runs against this relay's own routes in-process. A `model` in `create_session` or `send` is checked first, before any approval card, against the list New Chat offers for the provider on the target (`/api/models` for Copilot and OpenAI, the provider settings routes for Claude, Cursor, Grok and Claude Cloud): one it does not list is refused with `REMOTE_RELAY_UNKNOWN_MODEL`, `suggestions` (the close matches) and `models`, and nothing is created or sent. Casing follows the list; a Claude alias (`opus`, `sonnet`, `haiku`, with an optional `[1m]`) resolves to the newest listed model of its family by version number (a date suffix ignored, the undated id first, `[1m]` ids only when the alias asks and they are listed); a `[1m]` id with only its base listed runs as the base, with a note; a Copilot id with its effort (`gpt-5.4-high`) goes to the bootstrap and the first prompt as the listed id plus `reasoningEffort` (an explicit `effort` wins, and a note says so), but only when the catalogue lists that effort for the id (`reasoningByModel`): `gpt-5.1-codex-max` with only `gpt-5.1-codex` listed is unknown, with the base suggested; `auto` passes. `send` also takes the provider's whole discovered list (`availableModels`) and the session's own model, as the message route does. Without a list (discovery has not run, the route did not answer; `auto` alone is no list) the model goes on unchecked and `modelNote` says so, as it does when the bootstrap binds another model than the one asked. A provider not set up there answers `REMOTE_RELAY_PROVIDER_UNAVAILABLE` before its model is looked at. The bootstrap route itself checks a Copilot model from an agent against Copilot's own models: an unknown one is refused (`COPILOT_MODEL_UNAVAILABLE`, with `supportedModels` and `suggestedModels`; another provider's model gets that provider's answer), a listed one is bound in the list's casing, an effort-suffixed one as the listed id plus that effort when `reasoningByModel` lists it; the browser's New Chat keeps its fallback to the current model. `wait_seconds` is cut to the `maxWaitSeconds` setting for every target |
1345
1480
  | GET | `/api/relay-events/subscriptions` | Every active [relay event subscription](#relay-event-subscriptions) (`{ available, subscriptions }`); refused to other relays' agents |
1346
1481
  | DELETE | `/api/relay-events/subscriptions/:id` | The user's Unsubscribe (a paired relay's subscription too: it learns with its next fetch); refused to other relays' agents |
1347
1482
  | POST | `/api/relay-events/remote/subscribe` | (Relay to relay) A paired relay's agent subscribes to sessions here: `{ watcher: { conversationId, title }, subscriber: { name }, scope, sessions, events?, once?, match?, stalled_minutes?, heartbeat_minutes?, dry_run? }` → `{ ok, subscription }`. Another relay's request only, inbound switch on; see [Across paired relays](#across-paired-relays) |
@@ -1360,6 +1495,7 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
1360
1495
  - `reason` (optional string)
1361
1496
  - `requestedBy` (optional string)
1362
1497
  - `restart` (optional boolean-ish flag; `true` queues self-restart instead of plain shutdown)
1498
+ - `immediate` (optional boolean-ish flag; `true` does not wait for the running turns)
1363
1499
  - Example restart request:
1364
1500
 
1365
1501
  ```json
@@ -1376,11 +1512,48 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
1376
1512
  - `restart`: boolean mirror of the action
1377
1513
  - `requestedAt`, `reason`, `requestedBy`
1378
1514
  - `queue`: current `pendingCount` / `processingCount` / `parkedCount`
1379
- - The relay waits until no turn is processing before acting; this API is not an interrupt or cancel mechanism. While the request is queued no pending message is handed to a worker (a turn started meanwhile would postpone the restart again); pending and parked rows stay in the database and run after the restart.
1515
+ - `waitingFor`, `held`, `backgroundTasks`, `survival`, `maxWaitMs`, `deadlineAt`, `immediate` (below)
1516
+ - The relay waits until no turn is processing before acting. While the request is queued no pending message is handed to a worker (a turn started meanwhile would postpone the restart again); pending and parked rows stay in the database and run after the restart.
1517
+ - `immediate: true` (boolean-ish, like `restart`) starts it at once, running turns or not: the banner's **Restart now**. The answer still names what it did not wait for.
1518
+ - Maximum wait (`services/relay-restart-queue.mjs`): after **Ask after a queued relay restart has waited** (`relay_restart_max_wait_minutes`, 30 by default, 5–240 or `0` = no limit; `OAR_RELAY_RESTART_MAX_WAIT_MS` overrides it in ms, for test relays) the request stays queued and sets `question: { askedAt }`; the relay pushes one notification (titled *Relay restart waiting*; switch **Queued relay restart waited its maximum time**, `relayRestart`) and never restarts while turns run; it still goes ahead as soon as the last turn ends, question or not. A raised maximum (or none) that moves the deadline into the future withdraws the question; `/keep-waiting` without a question does nothing (`extended: false`). An `immediate` request may name the queued request it was shown (`expectRequestedAt`, its `requestedAt`); when that request is gone the answer is `accepted: false, stale: true` and nothing starts. Agents are told never to send `immediate` (`relay-tools.md`). The user answers with `immediate`, `/keep-waiting` (another full period from now; counted in `keptWaiting`) or `/cancel`. With no limit the request, and the messages it holds, wait until the turns end or someone decides.
1519
+ - What it waits for, holds and leaves running (`services/relay-restart-picture.mjs`), in the answer, in `GET /api/relay/shutdown`, in `/api/status` → `relayShutdown` and in the `relay_shutdown_state` socket event (sent on every change and on connect):
1520
+ - `waitingFor`: `[{ conversationId, title, turns }]`, the sessions with a processing turn
1521
+ - `held`: `{ count, conversations: [{ conversationId, title, count }] }`, pending and parked messages
1522
+ - `backgroundTasks`: `[{ conversationId, title, count, detail, survives, why }]`, sessions with running background work of a live worker (the task panel's sets and running sub-agent runs, through `background-work-view.mjs`), then sessions with console tasks held since the last restart (`background-task-store.mjs` `listRestored`), which no worker has confirmed yet: `survives: null`
1523
+ - `survival`: `{ action, workersSurvive, summary }`. Workers are separate processes (tmux, detached, a console window) and outlive a restart, which the relay's supervisor or the CLI extension takes; they outlive a shutdown too, except under a systemd service (detected from `INVOCATION_ID` and a `.service` cgroup), whose stop may take the workers with it (`survives: false`, shown as "may stop"; a worker in a tmux session the service did not start survives, which the relay cannot tell). A Claude Cloud session runs at Anthropic and always survives
1524
+ - while queued also `holdsNewTurns`, `maxWaitMs`, `waitingSince` (start of the current period), `deadlineAt` (`null` without a limit), `question`, `keptWaiting`
1380
1525
  - Ownership after an intentional restart follows the argv role, so exactly one supervisor acts on exit 75:
1381
1526
  - `server.js --supervised` exits 75 and relaunches under `.github/extensions/web-relay/server-lifecycle/managed-server.mjs`
1382
1527
  - bare `node server/server.js` keeps its supervisor attached, respawns the `--relay-runtime` child, and keeps the same terminal session alive
1383
1528
 
1529
+ ### Interrupted turns after a restart
1530
+
1531
+ `services/restart-resume-service.mjs` implements **After a relay restart, tell interrupted sessions
1532
+ to carry on** (`app_settings` `restart_resume_notes`, `'1'` on, off when absent; `GET/POST
1533
+ /api/settings/restart-resume` `{ enabled }`, a boolean, 400 otherwise). At boot it notes the queue
1534
+ rows that are `processing` (the turns the runtime before left) and follows them through the
1535
+ recovery paths, which report each outcome (`noteRestartTurnOutcome`):
1536
+
1537
+ | Path | `cut` (cannot run again) | `requeued` | `failed` |
1538
+ | --- | --- | --- | --- |
1539
+ | stale sweep (`recoverProcessingOlderThan`) | stale continuation dropped; consumed row failed | requeued | over the retry budget |
1540
+ | dead worker (`recoverDeadWorkerProcessingRows`) | continuation dropped; consumed row failed | requeued | over the retry budget |
1541
+ | owner heartbeat idle (`recoverOwnedProcessingRowsForSession`) | continuation dropped; consumed row failed; a started turn failed as `turn-replay-blocked` | requeued | |
1542
+
1543
+ A heartbeat that lists a row (`activeQueueMessageId(s)`) claims it: its worker lived through the
1544
+ restart and has the turn, so whatever ends it later is not put down to the restart, and the
1545
+ session gets no note. A row that leaves `processing` another way is read from the queue: back in `pending` or `parked` is
1546
+ `requeued`, anything else (a reply from a worker that lived through the restart) is settled. Once
1547
+ none of a conversation's rows of the boot is processing, it gets a note when one was `cut` and none
1548
+ was `requeued`, `claimed` or `failed`; the stale sweep's 15 s tick decides the ones that settled on their own,
1549
+ and the watch ends 30 minutes after the boot. The note is a message of the relay's (role `user`,
1550
+ `kind = 'relay-restart-resume'`) with a pending queue row on the interrupted turn's model, mode and
1551
+ owner, queued at the time it is written (behind a message the user queued meanwhile), as the
1552
+ usage-limit follow-up is; the primer then starts the worker. No note when the setting is off at
1553
+ that moment, for a deleted or archived conversation, while the conversation holds a usage-limit
1554
+ follow-up, or a second time in the same runtime. The browser renders it as a compact relay row
1555
+ (`relay-restart-note-view.mjs`).
1556
+
1384
1557
  ### Upload storage model
1385
1558
 
1386
1559
  - Physical blobs are stored in `server/uploads/<sha256>` (content-addressed).
@@ -1937,6 +2110,22 @@ the watched one `subscriber_relay_id`, `subscriber_relay_name`, `subscriber_watc
1937
2110
  `subscriber_watcher_title`, `last_pull_at`, `dropped_count`) and `relay_event_links` (per paired
1938
2111
  relay the fetch cursor and the link's health). See [Across paired relays](#across-paired-relays).
1939
2112
 
2113
+ `0017-conversation-history-indexes.mjs` adds two indexes and nothing else:
2114
+ `queue(response_message_id)` for the sub-agent records of each reply on a history page, and
2115
+ `messages(conversation_id, timestamp, id)` for selecting the page itself. `GET
2116
+ /api/conversation/:id` and the shared view select the requested page in SQL in conversation order
2117
+ (timestamp, then id) and hydrate only its replies, so a page costs the same in a conversation of
2118
+ any length. A conversation with a row whose timestamp is not the relay's ISO form
2119
+ (`YYYY-MM-DDTHH:MM:SS.mmmZ`) or whose id is not lowercase letters, digits and dashes is paged the
2120
+ old way: every row loaded, the page cut afterwards.
2121
+
2122
+ `0018-background-task-rows.mjs` adds `background_task_rows` (`store_key`, `task_id`, `state`
2123
+ `live` | `ended`, `row_json`, `ended_at`, `updated_at`; one row per key and task): the task
2124
+ console's rows across a relay restart (see
2125
+ [Background tasks and workflow progress](#background-tasks-and-workflow-progress)). Additive and
2126
+ idempotent; a failure is logged and retried on the next boot, and the store keeps its rows in
2127
+ memory meanwhile. An older build ignores the table.
2128
+
1940
2129
  `0007-claude-cloud.mjs` adds the storage of [Claude Cloud](#claude-cloud-provider) conversations.
1941
2130
  It is additive and idempotent, runs on every boot, and a failure is logged and retried on the next
1942
2131
  one:
@@ -81,6 +81,8 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
81
81
  subscription_id: text('subscription_id'),
82
82
  watcher: text('watcher'),
83
83
  from: text('from'),
84
+ task_id: text('task_id'),
85
+ lines: number('lines'),
84
86
  };
85
87
 
86
88
  export function createRemoteRelayToolDefinition({ api, getConversationId = () => '', dbg = () => {} } = {}) {
@@ -1,5 +1,6 @@
1
1
  import { createSdkMcpServer, query } from '@anthropic-ai/claude-agent-sdk';
2
2
 
3
+ import { formatClaudeBackgroundCommandLimitLabel } from '../../shared/claude-background-command-limit.mjs';
3
4
  import { parseThinkingDisplay } from '../../shared/claude-thinking.mjs';
4
5
  import { MEDIA_EMBED_INSTRUCTION_TEXT } from '../../shared/media-embed-instructions.mjs';
5
6
  import { REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS } from '../../shared/remote-relay-tool-core.mjs';
@@ -136,11 +137,57 @@ export function permissionModeForRelayMode(relayMode) {
136
137
  return 'default';
137
138
  }
138
139
 
139
- export function systemPromptForRelayMode(relayMode) {
140
+ // Bash timeouts the CLI reads from its environment (measured against CLI
141
+ // 2.1.292). BASH_DEFAULT_TIMEOUT_MS is the only lever on the background
142
+ // default (`max(30 min, BASH_DEFAULT_TIMEOUT_MS)`), but it also becomes the
143
+ // foreground default; CLAUDE_CODE_AUTO_BACKGROUND_TIMEOUT_MS therefore caps a
144
+ // main-agent foreground command at 2 min and then moves it to the background
145
+ // instead of letting it block the turn. BASH_MAX_TIMEOUT_MS lifts the ceiling
146
+ // an explicit `timeout` may ask for (foreground and background) to 8 h.
147
+ // Commands that start with `sleep` are never moved to the background by the
148
+ // CLI, and subagents get no auto-background; both run in the foreground for
149
+ // up to the default.
150
+ export const CLAUDE_BASH_MAX_TIMEOUT_MS = 8 * 3_600_000;
151
+ export const CLAUDE_AUTO_BACKGROUND_TIMEOUT_MS = 120_000;
152
+
153
+ /**
154
+ * The environment a Claude CLI spawn gets, or null to inherit the worker's
155
+ * own. `env` REPLACES the subprocess environment in the SDK, so the base is
156
+ * spread in.
157
+ */
158
+ export function claudeSpawnEnv({ backgroundCommandLimitMs = null, baseEnv = process.env } = {}) {
159
+ const limit = Number(backgroundCommandLimitMs);
160
+ if (backgroundCommandLimitMs === null || !Number.isFinite(limit) || limit <= 0) return null;
161
+ return {
162
+ ...baseEnv,
163
+ BASH_DEFAULT_TIMEOUT_MS: String(Math.round(limit)),
164
+ BASH_MAX_TIMEOUT_MS: String(Math.max(CLAUDE_BASH_MAX_TIMEOUT_MS, Math.round(limit))),
165
+ CLAUDE_CODE_AUTO_BACKGROUND_TIMEOUT_MS: String(CLAUDE_AUTO_BACKGROUND_TIMEOUT_MS),
166
+ };
167
+ }
168
+
169
+ /** How to run long commands, with the session's limit when it is known. */
170
+ export function longCommandInstructionText(backgroundCommandLimitMs = null) {
171
+ const limit = Number(backgroundCommandLimitMs);
172
+ const known = backgroundCommandLimitMs !== null && Number.isFinite(limit) && limit > 0;
173
+ const limitClause = known
174
+ ? `Background Bash commands are stopped after ${formatClaudeBackgroundCommandLimitLabel(limit / 60_000)} unless you pass a longer timeout (up to 8 h); `
175
+ : 'For long background Bash commands, ';
176
+ return `${limitClause}write the full output to a log file instead of piping it into tail or head, `
177
+ + 'since the pipe holds the output back and loses it if the command is stopped. '
178
+ + 'Start runs that must survive a session restart detached with nohup and a log file.';
179
+ }
180
+
181
+ export function systemPromptForRelayMode(relayMode, { backgroundCommandLimitMs = null } = {}) {
140
182
  const mode = String(relayMode || 'agent').trim().toLowerCase();
141
183
  // The media-embed capability rides every session — unlike the mode nudges it
142
- // is renderer knowledge the model cannot discover any other way.
143
- const append = [MEDIA_EMBED_INSTRUCTION_TEXT, MODE_SYSTEM_PROMPT_APPEND[mode]]
184
+ // is renderer knowledge the model cannot discover any other way. The long
185
+ // command note rides every session too: the limit is the relay's setting.
186
+ const append = [
187
+ MEDIA_EMBED_INSTRUCTION_TEXT,
188
+ longCommandInstructionText(backgroundCommandLimitMs),
189
+ MODE_SYSTEM_PROMPT_APPEND[mode],
190
+ ]
144
191
  .filter(Boolean)
145
192
  .join('\n\n');
146
193
  return {
@@ -233,6 +280,10 @@ export function startClaudeSession({
233
280
  autoCompactWindow = null,
234
281
  thinkingEnabled = null,
235
282
  attribution = null,
283
+ // How long a backgrounded Bash command may run without its own timeout
284
+ // (ms, the relay's setting); null leaves the CLI's defaults and the
285
+ // worker's environment alone.
286
+ backgroundCommandLimitMs = null,
236
287
  thinkingDisplay = '',
237
288
  abortController,
238
289
  canUseTool,
@@ -259,14 +310,16 @@ export function startClaudeSession({
259
310
  const relayMcpServer = typeof api === 'function'
260
311
  ? createRelayMcpServer({ api, getConversationId, dbg, remoteRelayTool: remoteRelayTool === true })
261
312
  : null;
313
+ const env = claudeSpawnEnv({ backgroundCommandLimitMs });
262
314
  const options = {
263
315
  cwd,
264
316
  permissionMode: permissionModeForRelayMode(relayMode),
265
- systemPrompt: systemPromptForRelayMode(relayMode),
317
+ systemPrompt: systemPromptForRelayMode(relayMode, { backgroundCommandLimitMs: env ? backgroundCommandLimitMs : null }),
266
318
  includePartialMessages: true,
267
319
  forwardSubagentText: true,
268
320
  ...(effortOption ? { effort: effortOption } : {}),
269
321
  ...(spawnSettings ? { settings: spawnSettings } : {}),
322
+ ...(env ? { env } : {}),
270
323
  ...(normalizedModel && normalizedModel !== 'auto' ? { model: String(model).trim() } : {}),
271
324
  ...(String(resume || '').trim() ? { resume: String(resume).trim() } : {}),
272
325
  ...(abortController ? { abortController } : {}),