@oar-sh/oar 0.9.11 → 0.9.12

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/CHANGELOG.md +126 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +17 -3
  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 +128 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +9 -0
  15. package/server/grok-worker/acp-host-services.mjs +42 -3
  16. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  17. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  18. package/server/grok-worker/grok-session-worker.mjs +33 -1
  19. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  20. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  21. package/server/public/app/api-client.js +13 -0
  22. package/server/public/app/background-tasks-view.mjs +285 -16
  23. package/server/public/app/bootstrap.js +95 -5
  24. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  25. package/server/public/app/conversation-payload-cache.mjs +357 -0
  26. package/server/public/app/conversation-turn-state.mjs +1 -1
  27. package/server/public/app/conversation-view.js +433 -3
  28. package/server/public/app/conversation-window-retention.mjs +96 -0
  29. package/server/public/app/journal-view.js +252 -66
  30. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  31. package/server/public/app/settings-modal.js +110 -2
  32. package/server/public/app/socket-handlers.js +59 -7
  33. package/server/public/app/store.js +25 -0
  34. package/server/public/app/subagent-viewer.mjs +7 -1
  35. package/server/public/app/task-console-render.mjs +237 -0
  36. package/server/public/app/task-console-watch.mjs +42 -0
  37. package/server/public/app/task-console.mjs +630 -0
  38. package/server/public/app/task-output-text.mjs +234 -0
  39. package/server/public/app/transcript-island.mjs +73 -0
  40. package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
  41. package/server/public/app/transcript-jump-bottom.mjs +48 -11
  42. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  43. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  44. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  45. package/server/public/index.html +239 -6
  46. package/server/relay-tools.md +1 -0
  47. package/server/repositories/message-repository.mjs +8 -0
  48. package/server/repositories/question-repository.mjs +4 -0
  49. package/server/routes/messages-routes.mjs +249 -3
  50. package/server/routes/sessions-routes.mjs +133 -63
  51. package/server/server-runtime.mjs +77 -0
  52. package/server/services/background-task-store.mjs +240 -3
  53. package/server/services/cli-process-runner.mjs +4 -21
  54. package/server/services/conversation-history-page.mjs +199 -0
  55. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  56. package/server/services/remote-relay-dispatcher.mjs +130 -2
  57. package/server/services/session-activity-tracker.mjs +6 -2
  58. package/server/services/task-output-rooms.mjs +259 -0
  59. package/server/services/task-output-service.mjs +562 -0
  60. package/shared/claude-background-command-limit.mjs +83 -0
  61. package/shared/remote-relay-contract.mjs +32 -2
  62. package/shared/task-output-text.mjs +737 -0
  63. package/shared/worker-runtime/task-output-spool.mjs +181 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,132 @@
3
3
  All notable changes to OAR are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
5
5
 
6
+ ## [0.9.12] — 2026-10-18
7
+
8
+ Highlights: a background command or monitor in the task panel opens a
9
+ console with its output, live, in colour and with secrets blanked, paging
10
+ back through the whole log and with its own **Stop**. Task rows say when a
11
+ task has gone quiet ("no output for 12 min", amber after a time you set in
12
+ Settings) and show a **✓ 412 ✘ 2** chip when the output is a test run;
13
+ sub-agent rows count their steps, and agents read a task's output on a
14
+ paired relay with `remote_relay` `read_task_output`. Long conversations open
15
+ and page back fast, and switching away and back, or reloading, returns to
16
+ the message you were reading, older pages included; new messages wait below
17
+ a place far up the history instead of skipping the ones in between.
18
+
19
+ ### Upgrade notes
20
+
21
+ - Migration 0017 (two indexes for the history of long conversations) runs by
22
+ itself on the first start. The Node.js requirement is unchanged (22.13 or
23
+ newer), and no setting or config key needs changing.
24
+ - Two new settings in **Settings › General**: **Mark a background task as
25
+ quiet after** (10 minutes by default, for the relay) and **Messages kept
26
+ per conversation for switching back** (200 by default, per browser).
27
+ - **Claude background commands may run for 2 hours** before Claude Code
28
+ stops them, and up to 8 hours when Claude asks; **Settings › Providers ›
29
+ Claude** sets the limit. OAR now sets `BASH_DEFAULT_TIMEOUT_MS`, `BASH_MAX_TIMEOUT_MS` and
30
+ `CLAUDE_CODE_AUTO_BACKGROUND_TIMEOUT_MS` for the Claude sessions it starts,
31
+ so values of these in the relay's environment no longer apply. Commands
32
+ that start with `sleep`, and sub-agents' commands, are not moved to the
33
+ background after 2 minutes and may block a turn up to the limit.
34
+
35
+ ### Added
36
+
37
+ - **Click a background command to watch its output.** A backgrounded shell
38
+ command or monitor in the composer's task panel opens a console: the last
39
+ 2,000 lines, following live, with **Load earlier** paging back through the
40
+ whole log (about 5 MB at most). Progress-bar redraws collapse to their last
41
+ state, repeated lines fold into one with ×N, colours are kept and other
42
+ control codes dropped, secrets are blanked, and the console has its own
43
+ **Stop**. A finished command keeps a dimmed ✓/✘ row for an hour so a failed
44
+ run can still be read. Claude reads the file its CLI writes for the command,
45
+ Copilot a detached shell's log or an attached shell's recent output, and
46
+ Grok now lists its terminals as tasks. A session started before this
47
+ release names no output file; its console says so and that a restart of
48
+ the session helps.
49
+ - **Task rows say when a task has gone quiet** ("no output for 12 min", amber
50
+ after **Settings › General › Mark a background task as quiet after**, 10
51
+ minutes by default) and show a **✓ 412 ✘ 2** chip when the output is a
52
+ vitest, Playwright, node:test, jest, pytest or turbo run (also through
53
+ pnpm); the console header's chip jumps to the first failure, and a run
54
+ that ends shows its final counts within a moment.
55
+ - **Sub-agent rows in the task panel show their step count** ("22 steps"), as
56
+ the rows in the reply already did.
57
+ - **Settings › Providers › Claude › Claude background command limit** (30
58
+ minutes to 8 hours, 2 hours by default) sets how long a command Claude runs
59
+ in the background may take before Claude Code stops it, when Claude gives it
60
+ no timeout of its own; Claude may still ask for up to 8 hours for one
61
+ command. A foreground command moves to the background after 2 minutes, so
62
+ the turn goes on while it runs. Claude is also told to write the output of
63
+ long runs to a log file instead of piping it into `tail`, and to start runs
64
+ that must survive a session restart with `nohup`. A change applies to Claude
65
+ sessions started afterwards; Claude Cloud is not affected.
66
+ - **`remote_relay` action `read_task_output`** gives agents the same tail,
67
+ quiet time and counts of a task on a relay they may read; `read_session`
68
+ lists the task ids with that health.
69
+
70
+ ### Changed
71
+
72
+ - **Long conversations open and scroll back fast.** A history page used to
73
+ load and prepare every message of the conversation, so a chat with a few
74
+ thousand messages took about two seconds per page and per live refresh. The
75
+ relay now reads only the requested page from the database (migration 0017
76
+ adds the indexes for it), so a page loads in well under a tenth of a second
77
+ however long the conversation is.
78
+ - Switching back to a conversation already viewed in this tab draws it at
79
+ once from what the page kept of it; the relay's fresh answer is merged into
80
+ it when it arrives.
81
+ - **Settings › General › Messages kept per conversation for switching back**
82
+ (200 by default, 100 to 1,000 or no limit, saved in this browser) sets how
83
+ many loaded messages each of the last eight conversations keeps.
84
+ - **The conversation list shows activity by the title's colour.** The three
85
+ animated dots after a working session's title are gone: a ready session
86
+ that runs a turn or background work keeps the bright green title, an idle
87
+ one turns a darker green (a muted green in the light theme), and the chat
88
+ header follows the same rule. Question, error, offline and archived titles
89
+ keep their colours.
90
+ - The npm package guard allows 7.5 MiB unpacked and 510 entries (was 7.25
91
+ MiB and 490); the task console, the transcript's handling of new
92
+ messages below a place far up the history, the Copilot task report and the
93
+ Claude background command limit grew the server and the app to about 7.37
94
+ MiB and 505 entries.
95
+
96
+ ### Fixed
97
+
98
+ - **Copilot background tasks report how they ended.** Finished shells and
99
+ sub-agents of Copilot's SDK engine showed the status *unknown* and no exit
100
+ code, in the task data agents read and wherever the end is shown. The
101
+ worker now reports completed, failed or cancelled, a shell's exit code, and
102
+ a sub-agent's result (or its error) as its summary; a passing status line
103
+ such as *Thinking…* is no longer kept as the final summary.
104
+ - **Cancel on a usage-limit pause removes the relay's held follow-up from the
105
+ open conversation at once.** The *Automatic message from OAR* row was
106
+ deleted on the relay but stayed on the page, and in what the page kept for
107
+ switching back, until a reload. A row deleted while you were elsewhere
108
+ leaves too, also when it was the conversation's newest.
109
+
110
+ - **Switching away from a long conversation and back keeps your place.** After
111
+ loading older messages and scrolling back into them, a switch to another
112
+ conversation and back lost the reading position: with more than 100 messages
113
+ loaded the oldest were dropped, and messages written meanwhile moved the view
114
+ by as many rows. The page now remembers the message you were reading and
115
+ keeps the loaded messages, so you come back to the same message at the same
116
+ place, older pages included; after a reload it opens at that message too.
117
+ - **New messages no longer skip the ones in between while you read far up.**
118
+ Opened at a pin, a search result or the place you were reading after a
119
+ reload, a conversation shows the messages around it. A message or reply that
120
+ arrived then was put right under them, and the messages between were never
121
+ loaded when you scrolled down, only the jump-to-bottom button brought them.
122
+ Now new messages wait: the button counts them, and scrolling down loads
123
+ every message in between, in order, before them. A reply still being
124
+ written shows once you are there. A message you send from there takes you
125
+ to the end, where it, its reply and everything in between show.
126
+ - **Images that load late no longer push the message you are reading down.**
127
+ In a browser without scroll anchoring (Safari, for one), pictures above
128
+ your place that arrived after a switch back, a reload or an older page moved
129
+ the text by their height. The transcript now holds your place while they
130
+ load, and lets go as soon as you scroll.
131
+
6
132
  ## [0.9.11] — 2026-10-17
7
133
 
8
134
  Highlights: sessions an agent starts now sit in a group under the session
package/README.md CHANGED
@@ -45,29 +45,29 @@ flowchart LR
45
45
  <table>
46
46
  <tr>
47
47
  <td width="42%" rowspan="4" valign="top">
48
- <a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.11"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.11"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.11" alt="OAR on a phone: a finished agent turn in a conversation, with the composer below"></picture></a>
48
+ <a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.12"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.12"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.12" alt="OAR on a phone: a finished agent turn in a conversation, with the composer below"></picture></a>
49
49
  <br><sub>On a phone: the same conversations, installable as an app.</sub>
50
50
  </td>
51
51
  <td width="58%" valign="top">
52
- <a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.11"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.11"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.11" alt="Background subagents running in parallel in the task panel, each with live token counts, elapsed time, and its own Stop button"></picture></a>
52
+ <a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.12"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.12"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.12" alt="Background subagents running in parallel in the task panel, each with live token counts, elapsed time, and its own Stop button"></picture></a>
53
53
  <br><sub>Agents working in parallel, each with live token counts and its own Stop.</sub>
54
54
  </td>
55
55
  </tr>
56
56
  <tr>
57
57
  <td valign="top">
58
- <a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.11"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.11"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.11" alt="A question card in the middle of a turn: the agent offers several choices and a free-text reply"></picture></a>
58
+ <a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.12"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.12"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.12" alt="A question card in the middle of a turn: the agent offers several choices and a free-text reply"></picture></a>
59
59
  <br><sub>When the agent needs a decision, it asks, and waits for your answer.</sub>
60
60
  </td>
61
61
  </tr>
62
62
  <tr>
63
63
  <td valign="top">
64
- <a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.11"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.11"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.11" alt="An expanded Thoughts block showing the agent's reasoning above its reply"></picture></a>
64
+ <a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.12"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.12"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.12" alt="An expanded Thoughts block showing the agent's reasoning above its reply"></picture></a>
65
65
  <br><sub>Read the reasoning, not just the answer.</sub>
66
66
  </td>
67
67
  </tr>
68
68
  <tr>
69
69
  <td valign="top">
70
- <a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.11"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.11"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.11" alt="A session watching two other sessions: a finished reply arrives as a compact relay-event row, the watching 2 sessions list is open with Unsubscribe, and a request to also watch a session on a paired relay waits on an Allow / Deny card"></picture></a>
70
+ <a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.12"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.12"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.12" alt="A session watching two other sessions: a finished reply arrives as a compact relay-event row, the watching 2 sessions list is open with Unsubscribe, and a request to also watch a session on a paired relay waits on an Allow / Deny card"></picture></a>
71
71
  <br><sub>Agents that watch agents: woken by events, approving nothing.</sub>
72
72
  </td>
73
73
  </tr>
@@ -116,11 +116,11 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
116
116
  <table>
117
117
  <tr>
118
118
  <td width="62%" valign="top">
119
- <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.11"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.11"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.11" alt="A running Claude turn with a message steered into it mid-turn, the reply streaming with its tool calls, and the Steer button in the composer"></picture></a>
119
+ <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.12"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.12"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.12" alt="A running Claude turn with a message steered into it mid-turn, the reply streaming with its tool calls, and the Steer button in the composer"></picture></a>
120
120
  <br><sub>Send a message while the agent works, and it steers the running turn.</sub>
121
121
  </td>
122
122
  <td width="38%" valign="top">
123
- <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.11"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.11"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.11" alt="The GitHub Copilot card in Check Usage: premium requests and plan quota with reset dates, and the cost of the last turn"></picture></a>
123
+ <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.12"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.12"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.12" alt="The GitHub Copilot card in Check Usage: premium requests and plan quota with reset dates, and the cost of the last turn"></picture></a>
124
124
  <br><sub>What each plan has left, in one place.</sub>
125
125
  </td>
126
126
  </tr>
@@ -129,7 +129,7 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
129
129
  - **Watch a turn live**: the reply, the agent's thoughts and every tool call appear as they happen, and each sub-agent gets a bubble of its own. **Stop** is always one tap away ([While a turn runs](docs/web-ui.md#while-a-turn-runs)).
130
130
  - **Steer or queue**: a message sent during a turn goes into it (Claude, Copilot) or waits behind it ([While a turn runs](docs/web-ui.md#while-a-turn-runs)).
131
131
  - **Answer questions**: the agent asks on a card with one-click choices, checkmarks or free text, and waits for you ([Question cards](docs/web-ui.md#question-cards)).
132
- - **Background work**: a task panel lists the commands and sub-agents that outlive a reply, each with its own **Stop** and its full record ([Background tasks](docs/web-ui.md#background-tasks)).
132
+ - **Background work**: a task panel lists the commands and sub-agents that outlive a reply, each with its own **Stop** and its full record; a command's console shows its output live, says when it has gone quiet and counts a test run's passes and failures ([Background tasks](docs/web-ui.md#background-tasks)).
133
133
  - **Choose how the agent works**: `ask`, `plan`, `agent` or `autopilot`, plus model and reasoning effort, per message ([Relay modes](docs/web-ui.md#relay-modes)).
134
134
  - **Files and git**: browse the workspace, preview files, review git changes in a diff viewer, and publish a dev server as a preview link ([Files, git, and previews](docs/web-ui.md#files-git-and-previews), [preview servers](docs/preview-servers.md)).
135
135
  - **Usage and context**: plan limits and credits of every provider, and what fills the model's context window ([Usage and context](docs/web-ui.md#usage-and-context)).
@@ -137,7 +137,7 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
137
137
  - **Share** a conversation as a read-only link, hide single messages and the host's paths from it, and see when it was opened ([Sharing](docs/web-ui.md#sharing)).
138
138
  - **On your phone**: install OAR as an app and get push notifications ([Notifications and the app](docs/web-ui.md#notifications-and-the-app)).
139
139
 
140
- The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.11](CHANGELOG.md#0911--2026-10-17) as the latest.
140
+ The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.12](CHANGELOG.md#0912--2026-10-18) as the latest.
141
141
 
142
142
  ## Remote access
143
143
 
@@ -188,7 +188,7 @@ flowchart LR
188
188
 
189
189
  You stay in charge: per paired relay you decide whether agents may only read, also prompt, or do everything, and in the `ask` and `plan` modes every write asks you first. A subscription only notifies; it approves nothing.
190
190
 
191
- <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.11"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.11"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.11" alt="A session watching two sessions on a paired relay: a reply arrives as a relay-event row, and after a short outage the events the other relay kept arrive as one replayed row" width="720"></picture></a></p>
191
+ <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.12"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.12"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.12" alt="A session watching two sessions on a paired relay: a reply arrives as a relay-event row, and after a short outage the events the other relay kept arrive as one replayed row" width="720"></picture></a></p>
192
192
 
193
193
  How pairing, permissions and limits work is in [Agents working together](docs/agents-together.md).
194
194
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
package/server/README.md CHANGED
@@ -899,7 +899,12 @@ turn processing, or queued and not held by a usage-limit pause; `background_only
899
899
  `subagent` | `shell`, `description`, `startedAt`, `lastActivityAt`, `status` `running` |
900
900
  `completed` | `failed` | `stopped`, `exitCode`, `summary`, `endedAt`) and `lastActivityAt`.
901
901
  `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.
902
+ `lastActivityAt`; `active` stays and is true for background work too. `read_session` adds to each
903
+ task its `taskId`, `output` (whether it has output to read) and, for a task with an output file,
904
+ `health` (`minutesSinceOutput`, `quiet`, `quietAfterMinutes`, test-run `counts`);
905
+ `read_task_output {session, task_id, lines?}` (read permission; default 200 lines, at most 500 or
906
+ 32 KB) reads the end of that output through `GET /api/conversation/:id/background-task/:taskId/output`,
907
+ scrubbed of credentials.
903
908
 
904
909
  ### Browser
905
910
 
@@ -1301,8 +1306,8 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
1301
1306
  | 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
1307
  | GET | `/api/settings/cursor-allowance` | Read the manual Cursor plan allowances (`cursorModelsUsd`, `otherModelsUsd`, `resetDay`) |
1303
1308
  | 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 |
1309
+ | GET | `/api/settings/claude` | Read Claude provider settings (`enabled`, `model`, `models`, `availableModels`, `attributionMode`, `backgroundCommandLimitMinutes` with the slider bounds `backgroundCommandLimitMinMinutes`, `backgroundCommandLimitMaxMinutes`, `backgroundCommandLimitStepMinutes`, `backgroundCommandLimitDefaultMinutes`) |
1310
+ | 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
1311
  | 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
1312
  | 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
1313
  | 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` |
@@ -1937,6 +1942,15 @@ the watched one `subscriber_relay_id`, `subscriber_relay_name`, `subscriber_watc
1937
1942
  `subscriber_watcher_title`, `last_pull_at`, `dropped_count`) and `relay_event_links` (per paired
1938
1943
  relay the fetch cursor and the link's health). See [Across paired relays](#across-paired-relays).
1939
1944
 
1945
+ `0017-conversation-history-indexes.mjs` adds two indexes and nothing else:
1946
+ `queue(response_message_id)` for the sub-agent records of each reply on a history page, and
1947
+ `messages(conversation_id, timestamp, id)` for selecting the page itself. `GET
1948
+ /api/conversation/:id` and the shared view select the requested page in SQL in conversation order
1949
+ (timestamp, then id) and hydrate only its replies, so a page costs the same in a conversation of
1950
+ any length. A conversation with a row whose timestamp is not the relay's ISO form
1951
+ (`YYYY-MM-DDTHH:MM:SS.mmmZ`) or whose id is not lowercase letters, digits and dashes is paged the
1952
+ old way: every row loaded, the page cut afterwards.
1953
+
1940
1954
  `0007-claude-cloud.mjs` adds the storage of [Claude Cloud](#claude-cloud-provider) conversations.
1941
1955
  It is additive and idempotent, runs on every boot, and a failure is logged and retried on the next
1942
1956
  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 } : {}),
@@ -58,6 +58,13 @@ import {
58
58
  withSlashCommandGuardContent,
59
59
  } from '../../shared/slash-command-guard.mjs';
60
60
  import { createUsageLimitTracker } from '../../shared/claude-usage-limit.mjs';
61
+ import {
62
+ discoverTaskOutputPaths,
63
+ exitCodeFromSummary,
64
+ isShellTaskType,
65
+ outputDirOf,
66
+ usableOutputPath,
67
+ } from './task-output-discovery.mjs';
61
68
 
62
69
  /**
63
70
  * Which system-prompt append a relay mode gets (claude-sdk-adapter's
@@ -77,6 +84,8 @@ export { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT };
77
84
  /** Task types that run a model of their own (vs. bash/monitor/workflow). */
78
85
  const AGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
79
86
  const MAX_TASK_TOOL_CALL_CHARS = 300;
87
+ /** Output paths remembered per process (one per backgrounded shell). */
88
+ const MAX_TASK_OUTPUT_PATHS = 200;
80
89
 
81
90
  // ---------------------------------------------------------------------------
82
91
  // Workflow progress sources (CLI 2.1.226 on-disk formats)
@@ -460,6 +469,10 @@ export function createClaudeSessionRunner({
460
469
  // leaves the CLI's own attribution. Piggybacked on deliveries like the
461
470
  // window above.
462
471
  getAttribution = () => null,
472
+ // How long a backgrounded Bash command may run without its own timeout
473
+ // (ms; the relay's Claude setting). Read at spawn only: the CLI takes it
474
+ // from its environment, so a change applies to the next process.
475
+ getBackgroundCommandLimitMs = () => null,
463
476
  // The per-conversation thinking state ({enabled, display}); read per turn
464
477
  // like the window, so a settings change reaches a running process on its
465
478
  // next delivery.
@@ -1769,6 +1782,9 @@ export function createClaudeSessionRunner({
1769
1782
  // The workflow digest (poller below) rides the same row; the relay
1770
1783
  // sanitizer whitelists and re-clamps it before storing.
1771
1784
  ...(task.workflowProgress ? { workflowProgress: task.workflowProgress } : {}),
1785
+ ...(agentLike && Number.isInteger(task.steps) ? { steps: task.steps } : {}),
1786
+ // Where the relay reads the task's console from.
1787
+ ...(isShellTaskType(task.taskType) ? taskOutputFields(processRef, taskId) : {}),
1772
1788
  };
1773
1789
  });
1774
1790
  return postTaskSet({ tasks, ...(resync ? { resync: true } : {}) }).catch((error) => {
@@ -1788,6 +1804,89 @@ export function createClaudeSessionRunner({
1788
1804
  return Promise.resolve();
1789
1805
  }
1790
1806
 
1807
+ // ---------------------------------------------------------------------------
1808
+ // Task output files
1809
+ //
1810
+ // The relay reads a shell task's console straight from the file the CLI
1811
+ // writes (`<tasks dir>/<taskId>.output`); the worker only names it. Bash
1812
+ // results announce the path (task-output-discovery.mjs); a Monitor result
1813
+ // does not, but its file follows the same scheme next to any known one, and
1814
+ // is named only once it exists. Without a path the relay falls back to the
1815
+ // CLI's `getTaskOutput` snapshot (task_output_snapshot control).
1816
+
1817
+ function rememberTaskOutputPath(processRef, taskId, outputPath) {
1818
+ const id = String(taskId || '').trim();
1819
+ const usable = usableOutputPath(outputPath);
1820
+ if (!id || !usable) return false;
1821
+ const dir = outputDirOf(usable);
1822
+ if (dir) processRef.taskOutputDir = dir;
1823
+ // The first path a task names wins, here as on the relay.
1824
+ if (processRef.taskOutputPaths.has(id)) return false;
1825
+ processRef.taskOutputPaths.set(id, usable);
1826
+ while (processRef.taskOutputPaths.size > MAX_TASK_OUTPUT_PATHS) {
1827
+ processRef.taskOutputPaths.delete(processRef.taskOutputPaths.keys().next().value);
1828
+ }
1829
+ return true;
1830
+ }
1831
+
1832
+ function taskOutputFields(processRef, taskId) {
1833
+ const known = processRef.taskOutputPaths.get(taskId);
1834
+ if (known) return { outputPath: known, outputKind: 'file' };
1835
+ if (processRef.taskOutputDir) {
1836
+ const candidate = nodePath.join(processRef.taskOutputDir, `${taskId}.output`);
1837
+ let exists = false;
1838
+ try { exists = nodeFs.existsSync(candidate); } catch {}
1839
+ if (exists && rememberTaskOutputPath(processRef, taskId, candidate)) {
1840
+ return { outputPath: candidate, outputKind: 'file' };
1841
+ }
1842
+ }
1843
+ return { outputKind: 'snapshot' };
1844
+ }
1845
+
1846
+ /** A backgrounded Bash/Monitor result: note its file, and show it on the live row. */
1847
+ function recordTaskOutputPaths(sdkMessage) {
1848
+ let liveChanged = false;
1849
+ for (const { taskId, outputPath } of discoverTaskOutputPaths(sdkMessage)) {
1850
+ if (rememberTaskOutputPath(proc, taskId, outputPath) && proc.liveTasks.has(taskId)) liveChanged = true;
1851
+ }
1852
+ if (liveChanged) publishBackgroundTasks();
1853
+ }
1854
+
1855
+ /**
1856
+ * The relay's fallback for a shell task with no file: the CLI's last 8 KiB
1857
+ * (`Query.getTaskOutput`, implemented in sdk.mjs though sdk.d.ts does not
1858
+ * declare it), posted for the relay to serve. Asked once per viewer open.
1859
+ */
1860
+ async function snapshotTaskOutput(taskId) {
1861
+ const id = String(taskId || '').trim();
1862
+ const processRef = proc;
1863
+ if (!id || typeof processRef?.turn?.getTaskOutput !== 'function') {
1864
+ dbg('task output snapshot unavailable', id || '(no task)');
1865
+ return false;
1866
+ }
1867
+ let answer;
1868
+ try {
1869
+ answer = await processRef.turn.getTaskOutput(id);
1870
+ } catch (error) {
1871
+ dbg('getTaskOutput failed', id, error?.message || String(error));
1872
+ return false;
1873
+ }
1874
+ const output = typeof answer?.output === 'string' ? answer.output : '';
1875
+ const totalBytes = Number(answer?.total_bytes);
1876
+ try {
1877
+ await api('POST', `/api/background-tasks/${encodeURIComponent(id)}/output-snapshot`, {
1878
+ conversationId: sdkSessionId,
1879
+ output,
1880
+ totalBytes: Number.isFinite(totalBytes) && totalBytes >= 0 ? totalBytes : Buffer.byteLength(output),
1881
+ truncated: answer?.truncated === true,
1882
+ });
1883
+ return true;
1884
+ } catch (error) {
1885
+ dbg('task output snapshot post failed', id, error?.message || String(error));
1886
+ return false;
1887
+ }
1888
+ }
1889
+
1791
1890
  // ---------------------------------------------------------------------------
1792
1891
  // Workflow progress poller
1793
1892
  //
@@ -2226,6 +2325,11 @@ export function createClaudeSessionRunner({
2226
2325
  recordSubagentActivity(sdkMessage);
2227
2326
  return;
2228
2327
  }
2328
+ if (type === 'user') {
2329
+ // Any agent's backgrounded shell, the main thread's or a subagent's.
2330
+ recordTaskOutputPaths(sdkMessage);
2331
+ return;
2332
+ }
2229
2333
  if (type !== 'system') return;
2230
2334
  const subtype = String(sdkMessage.subtype || '');
2231
2335
  if (subtype === 'init') {
@@ -2524,6 +2628,9 @@ export function createClaudeSessionRunner({
2524
2628
  known.toolUseId = String(sdkMessage.tool_use_id || '').trim() || known.toolUseId || '';
2525
2629
  const totalTokens = Number(sdkMessage.usage?.total_tokens);
2526
2630
  if (Number.isFinite(totalTokens)) known.totalTokens = totalTokens;
2631
+ // The sub-agent's tool calls so far: the panel row's step count.
2632
+ const toolUses = Number(sdkMessage.usage?.tool_uses);
2633
+ if (Number.isFinite(toolUses) && toolUses >= 0) known.steps = Math.floor(toolUses);
2527
2634
  publishBackgroundTasks({ throttled: true });
2528
2635
  }
2529
2636
  return;
@@ -2543,6 +2650,16 @@ export function createClaudeSessionRunner({
2543
2650
  // tell its watchers the end. A failed post is kept and sent again.
2544
2651
  const published = proc.liveTasks.get(taskId) || proc.departedTasks.get(taskId) || null;
2545
2652
  proc.departedTasks.delete(taskId);
2653
+ // A shell's file and exit code make the relay's ended row: its
2654
+ // console stays readable after the task is gone.
2655
+ rememberTaskOutputPath(proc, taskId, sdkMessage.output_file);
2656
+ const shellLike = isShellTaskType(published?.taskType);
2657
+ const outputPath = shellLike ? (proc.taskOutputPaths.get(taskId) || '') : '';
2658
+ const exitCode = shellLike ? exitCodeFromSummary(sdkMessage.summary) : null;
2659
+ const toolUses = Number(sdkMessage.usage?.tool_uses);
2660
+ const steps = AGENT_TASK_TYPES.has(String(published?.taskType || '')) && Number.isFinite(toolUses) && toolUses >= 0
2661
+ ? Math.floor(toolUses)
2662
+ : null;
2546
2663
  void keptTaskSettles.post('/api/background-task-settled', {
2547
2664
  conversationId: sdkSessionId,
2548
2665
  taskId,
@@ -2555,6 +2672,9 @@ export function createClaudeSessionRunner({
2555
2672
  taskType: String(published.taskType || ''),
2556
2673
  startedAt: published.startedAt || null,
2557
2674
  } : {}),
2675
+ ...(outputPath ? { outputPath } : {}),
2676
+ ...(exitCode !== null ? { exitCode } : {}),
2677
+ ...(steps !== null ? { steps } : {}),
2558
2678
  });
2559
2679
  }
2560
2680
  // A settled session-level task means the CLI is about to dequeue a
@@ -3365,6 +3485,7 @@ export function createClaudeSessionRunner({
3365
3485
  const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
3366
3486
  const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
3367
3487
  const attribution = getAttribution() || null;
3488
+ const backgroundCommandLimitMs = getBackgroundCommandLimitMs() ?? null;
3368
3489
  const abortController = new AbortController();
3369
3490
  const processRef = {
3370
3491
  turn: null,
@@ -3391,6 +3512,11 @@ export function createClaudeSessionRunner({
3391
3512
  // taskId → the row of a task that left the live set, until its
3392
3513
  // notification arrives.
3393
3514
  departedTasks: new Map(),
3515
+ // taskId → the file a shell task's output goes to (first named wins),
3516
+ // and the directory of the last one seen: a Monitor task's file sits
3517
+ // next to it.
3518
+ taskOutputPaths: new Map(),
3519
+ taskOutputDir: '',
3394
3520
  // taskId → { runId, labels, digestJson } for live local_workflow tasks;
3395
3521
  // pruned by syncWorkflowPoller when a task leaves the live set.
3396
3522
  workflowStates: new Map(),
@@ -3538,6 +3664,7 @@ export function createClaudeSessionRunner({
3538
3664
  thinkingEnabled,
3539
3665
  thinkingDisplay,
3540
3666
  attribution,
3667
+ backgroundCommandLimitMs,
3541
3668
  abortController,
3542
3669
  canUseTool,
3543
3670
  pathToClaudeCodeExecutable,
@@ -4121,6 +4248,7 @@ export function createClaudeSessionRunner({
4121
4248
  isTurnActive,
4122
4249
  stopBackgroundTask,
4123
4250
  stopBackgroundTaskByToolUseId,
4251
+ snapshotTaskOutput,
4124
4252
  backgroundTaskIds,
4125
4253
  resyncBackgroundTasks,
4126
4254
  flushKeptTaskSettles,