@oar-sh/oar 0.9.21 → 0.9.22

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 (69) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
  5. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  6. package/server/claude-worker/claude-session-process.mjs +9 -4
  7. package/server/claude-worker/claude-session-worker-main.mjs +10 -1
  8. package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
  9. package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
  10. package/server/cursor-worker/cursor-session-worker.mjs +9 -1
  11. package/server/db-schema.mjs +19 -0
  12. package/server/grok-worker/grok-background-tasks.mjs +1 -0
  13. package/server/grok-worker/grok-session-worker.mjs +10 -1
  14. package/server/grok-worker/grok-turn-runner.mjs +1 -0
  15. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  16. package/server/migrations/0038-turn-seq.mjs +92 -0
  17. package/server/public/app/activity-replay-state.mjs +160 -42
  18. package/server/public/app/api-client.js +7 -0
  19. package/server/public/app/ask-user-view.js +4 -3
  20. package/server/public/app/bootstrap.js +62 -4
  21. package/server/public/app/chat-actions-menu-state.mjs +2 -0
  22. package/server/public/app/context-usage-view.mjs +68 -0
  23. package/server/public/app/conversation-view.js +306 -49
  24. package/server/public/app/header-priority-actions.mjs +5 -1
  25. package/server/public/app/held-details.mjs +202 -0
  26. package/server/public/app/inbox-ui.js +70 -79
  27. package/server/public/app/inbox-view.mjs +35 -19
  28. package/server/public/app/journal-view.js +17 -0
  29. package/server/public/app/live-activity-cap.mjs +103 -0
  30. package/server/public/app/questions-panel-ui.js +634 -0
  31. package/server/public/app/questions-stub-view.mjs +40 -0
  32. package/server/public/app/questions-view.mjs +89 -0
  33. package/server/public/app/relay-board-view.js +12 -2
  34. package/server/public/app/relay-thoughts.mjs +15 -1
  35. package/server/public/app/session-history-ui.js +1 -0
  36. package/server/public/app/settings-modal.js +6 -0
  37. package/server/public/app/socket-handlers.js +42 -11
  38. package/server/public/app/stream-view-preference.mjs +60 -0
  39. package/server/public/app/subagent-fold.mjs +6 -3
  40. package/server/public/app/subagent-timeline-view.mjs +12 -5
  41. package/server/public/app/subagent-viewer.mjs +8 -6
  42. package/server/public/app/thoughts-preference.mjs +79 -0
  43. package/server/public/app/turn-stream-view.mjs +124 -0
  44. package/server/public/index.html +391 -214
  45. package/server/repositories/message-repository.mjs +10 -0
  46. package/server/repositories/question-repository.mjs +51 -7
  47. package/server/repositories/relay-turn-rows.mjs +56 -0
  48. package/server/routes/ask-user-routes.mjs +34 -5
  49. package/server/routes/messages-routes.mjs +35 -14
  50. package/server/routes/relay-ask-routes.mjs +15 -5
  51. package/server/routes/sessions-routes.mjs +76 -5
  52. package/server/server-runtime.mjs +178 -131
  53. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  54. package/server/services/idle-timers.mjs +184 -0
  55. package/server/services/queue-wake-hook.mjs +145 -0
  56. package/server/services/queue-waker.mjs +127 -0
  57. package/server/services/relay-ask-service.mjs +64 -5
  58. package/server/services/relay-event-service.mjs +4 -1
  59. package/server/services/remote-relay-dispatcher.mjs +23 -4
  60. package/server/services/session-history-refresh-service.mjs +4 -0
  61. package/server/services/session-worker-supervisor-service.mjs +36 -0
  62. package/server/services/session-worker-websocket-service.mjs +143 -15
  63. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  64. package/shared/ask-user-bridge.mjs +9 -6
  65. package/shared/relay-asks.mjs +10 -0
  66. package/shared/relay-events.mjs +1 -0
  67. package/shared/worker-runtime/heartbeat.mjs +26 -0
  68. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  69. package/shared/worker-runtime/worker-websocket-link.mjs +21 -10
package/CHANGELOG.md CHANGED
@@ -3,6 +3,119 @@
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.22] — 2026-11-05
7
+
8
+ Highlights: questions that don't block move into the chat header. A **📓**
9
+ icon with a count opens them, urgent ones first, and the turn that asked
10
+ keeps one line, "📓 Question for you", in place of the card. Settings →
11
+ General reads as five headed sections, with three new choices: whether
12
+ thoughts start open while an agent works, how many tool lines a working
13
+ agent shows, counted per agent so a busy sub-agent no longer pushes out the
14
+ main agent's lines, and a stream view that shows a turn's thoughts and tool
15
+ calls in the order they happened. While a turn runs, the page's poll leaves
16
+ out what it already holds, and an idle relay and its workers wake far less
17
+ often. Claude sessions now ask you in a **Safety check** card, which only
18
+ you can answer, before removals and other asks Claude Code marks as
19
+ person-only.
20
+
21
+ ### Changed
22
+
23
+ - **📓 Questions in the chat header.** A conversation's questions that don't
24
+ block sit behind a 📓 icon with a count, first among the header icons (in
25
+  when the header runs out of room). It opens a list under it with the
26
+ full cards, urgent ones first with their details open, and what was
27
+ answered in the last day below; answering keeps the list open. On a phone
28
+ the list spans the width over a dimmed page, with half-width buttons. While
29
+ an urgent question waits, 📓 and the Inbox show the urgent count in red.
30
+ The Inbox's link to a session opens it with 📓 at that question. Each ask
31
+ now remembers the turn that asked it (migration 0037).
32
+ - **A question leaves one line in the turn that asked it.** The cards of
33
+ questions that don't block no longer sit at the end of the conversation;
34
+ the reply of the turn that asked shows a one-line "📓 Question for you"
35
+ instead (live while it runs, "Answered: â€Ļ" once answered), and a tap opens
36
+ 📓 at that question. A question asked for another session, carried over by
37
+ a compaction or from before this version stands as one line at the end
38
+ while it waits.
39
+ - **Settings → General in headed sections.** The tab reads as five groups,
40
+ Appearance, Reading, Turns and tasks, This relay and Updates, instead of
41
+ one long column. Each relay-wide row now says it is a setting of this
42
+ relay; the per-browser rows keep "Saved in this browser."
43
+ - **Thoughts open or closed by your choice.** Settings → General →
44
+ Appearance has "Expand thoughts while an agent works" (on by default), with
45
+ a 💭 Thoughts mirror in the 🧠 modal. It decides whether thoughts start open
46
+ in the live turn, in live sub-agent bubbles and in the sub-agent viewer,
47
+ finished thoughts there included. An open live thought keeps a one-line
48
+ header to fold it by; a thought you fold or open by hand stays that way
49
+ while more text streams in. A finished reply's 💭 fold stays closed as
50
+ before.
51
+ - **The main agent's tool lines stay while sub-agents work.** The live
52
+ bubble's tool list is capped per agent now, the main agent and each
53
+ sub-agent apart, so a busy sub-agent no longer pushes the main agent's
54
+ lines out. Settings → General → Appearance has "Tool lines shown while an
55
+ agent works": 50 by default, 10 to 500 or no limit. The relay sends the
56
+ running turn's newest lines per agent as the page's setting asks (50 when
57
+ it does not say, 500 at most), and for a finished turn the main agent's
58
+ newest 200 and each sub-agent's newest 50 (it sent the first 48 of the
59
+ whole turn). A sub-agent bubble that holds as many lines as the setting
60
+ shows "50+ steps".
61
+ - **Stream view: a turn in the order it happened.** Settings → General →
62
+ Appearance has "Stream view" (off by default), with a đŸ§ĩ mirror in the 🧠
63
+ modal. When it is on, an agent turn's thoughts and tool calls show as one
64
+ list in the order they happened, live and in the finished message, with
65
+ the reply last. For Copilot, Cursor and Grok, tools and thoughts stream in
66
+ arrival order and the reply comes after them. The relay now numbers a
67
+ turn's thoughts and tool lines in one sequence (migration 0038); turns
68
+ recorded before that keep the grouped layout. A finished turn whose main
69
+ agent ran more than 200 tool lines says how many earlier ones it left out.
70
+ - **The live poll leaves out what the page already has.** While a turn
71
+ runs, the page asks for its conversation about once a second. It now
72
+ names the finished replies whose tool lines and thoughts it already holds,
73
+ and the relay leaves those out unless they changed since (a busy window:
74
+ about 6 KB per poll instead of over 200 KB). Opening a conversation,
75
+ paging back and every other load still get everything.
76
+ - **An idle relay and its workers wake far less often.** The relay no
77
+ longer polls its queue twice a second: a write that makes a message
78
+ deliverable wakes delivery at once, through triggers on the queue table.
79
+ A retry or a usage-limit hold gets a timer for the moment it ends, and a
80
+ 10 s poll stays as a backstop. A gate lifting (relay resumed, restart
81
+ cancelled, tunnel connected, a kill's grace over, a worker replacement
82
+ done) wakes delivery too. The CLI status, the share-viewer prune and the
83
+ worker prime run only while there is something for them to do. Each
84
+ worker has one keepalive instead of two 10 s timers, and it beats every
85
+ 30 s while idle, on a relay that supports it. A silent worker now counts
86
+ as gone after 90 s instead of 30 s. Workers and relays of older versions
87
+ keep working with the new ones.
88
+ - **Claude sessions ask you before removals and other asks Claude Code
89
+ marks as person-only, instead of allowing them.** Such an ask, for
90
+ example an `rm -rf` of the working folder, used to be approved by the
91
+ relay like every other ask. Now a **Safety check** card, in the session
92
+ and in the Inbox, shows Claude Code's reason
93
+ and then the whole command, each line behind `│`, with hidden or
94
+ reordering characters shown as `\u{â€Ļ}`. It offers **Allow once** and
95
+ **Deny**, takes no typed reply, and only you can answer it: no agent,
96
+ of this relay or another, sees it among a session's questions or may
97
+ answer it. It allows only that one call and adds no rule. A denial, no
98
+ answer within the question timeout, a stopped turn or a relay that
99
+ cannot show the card all leave the command unrun, and a command too long
100
+ to show is refused without a card. Other asks are still allowed as
101
+ before.
102
+
103
+ ### Upgrade notes
104
+
105
+ - **Two small migrations.** 0037 adds the asking turn to each ask and 0038
106
+ the sequence number to a turn's thoughts and tool lines, with an index;
107
+ neither rewrites the rows already there. As before, the relay copies its
108
+ database to the backups folder before the first start of a new version.
109
+ - **Every session worker is replaced once after the update,** as in
110
+ 0.9.21: once its session is idle and its CLI has ended, usually some 10
111
+ minutes after its last turn. A Claude session shows the **Safety check**
112
+ card once its worker runs 0.9.22. Nothing needs to be done.
113
+ - **Mixed versions work together.** A 0.9.21 worker on a 0.9.22 relay keeps
114
+ its 10 s beat, and a 0.9.22 worker on a 0.9.21 relay beats every 10 s.
115
+ Paired relays of both versions keep working with each other.
116
+ - **No new dependency.** Going back to 0.9.21 needs no database step: it
117
+ leaves the new columns and the index alone.
118
+
6
119
  ## [0.9.21] — 2026-11-01
7
120
 
8
121
  Highlights: the chat screen is more compact. The mode, model, effort,
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.21"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.21"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.21" 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.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.22" 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.21"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.21"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.21" 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.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.22" 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.21"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.21"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.21" 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.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.22" 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.21"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.21"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.21" 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.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.22" 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.21"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.21"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.21" 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.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.22" 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>
@@ -118,11 +118,11 @@ API keys are entered in **âš™ī¸ Settings** and stay on the relay host. The Clau
118
118
  <table>
119
119
  <tr>
120
120
  <td width="62%" valign="top">
121
- <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.21"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.21"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.21" 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>
121
+ <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.22" 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>
122
122
  <br><sub>Send a message while the agent works, and it steers the running turn.</sub>
123
123
  </td>
124
124
  <td width="38%" valign="top">
125
- <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.21"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.21"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.21" 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>
125
+ <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.22" 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>
126
126
  <br><sub>What each plan has left, in one place.</sub>
127
127
  </td>
128
128
  </tr>
@@ -139,7 +139,7 @@ API keys are entered in **âš™ī¸ Settings** and stay on the relay host. The Clau
139
139
  - **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)).
140
140
  - **On your phone**: install OAR as an app and get push notifications; back walks through the conversations you opened, a pinch zooms the whole app, and held sideways it keeps a phone's layout ([Notifications and the app](docs/web-ui.md#notifications-and-the-app), [back and forward](docs/web-ui.md#back-and-forward), [zoom](docs/web-ui.md#zoom), [a phone held sideways](docs/web-ui.md#a-phone-held-sideways)).
141
141
 
142
- 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.21](CHANGELOG.md#0921--2026-11-01) as the latest.
142
+ 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.22](CHANGELOG.md#0922--2026-11-05) as the latest.
143
143
 
144
144
  ## Remote access
145
145
 
@@ -190,7 +190,7 @@ flowchart LR
190
190
 
191
191
  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.
192
192
 
193
- <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.21"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.21"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.21" 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>
193
+ <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.22" 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>
194
194
 
195
195
  How pairing, permissions and limits work is in [Agents working together](docs/agents-together.md).
196
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.21",
3
+ "version": "0.9.22",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -14,6 +14,7 @@ import {
14
14
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
15
15
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
16
16
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
17
+ import { createWorkerKeepalive } from '../../shared/worker-runtime/worker-keepalive.mjs';
17
18
  import { resolveBuildId } from '../../shared/build-id.mjs';
18
19
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
19
20
  import { createControlPoller } from '../../shared/control-poller.mjs';
@@ -124,8 +125,13 @@ async function main() {
124
125
  getSteeringState: () => buildClaudeCloudSteeringState(runner),
125
126
  });
126
127
 
128
+ // One timer for the heartbeat and the socket's readiness refresh: 10 s
129
+ // while working, 30 s while idle on a relay that allows it.
130
+ let keepalive = null;
127
131
  const wsLink = createWorkerWebSocketLink({
128
132
  serverUrl,
133
+ readyRefreshMs: 0,
134
+ onRelayFrame: () => keepalive?.nudge(),
129
135
  token,
130
136
  dbg,
131
137
  buildId,
@@ -142,10 +148,12 @@ async function main() {
142
148
  }
143
149
  },
144
150
  });
151
+ keepalive = createWorkerKeepalive({ heartbeat, link: wsLink, busyMs: HEARTBEAT_MS });
145
152
 
146
153
  const shutdown = async (signal) => {
147
154
  dbg(`shutting down (${signal})`);
148
155
  try { wsLink.stop(); } catch {}
156
+ try { keepalive.stop(); } catch {}
149
157
  try { heartbeat.stopHeartbeat(); } catch {}
150
158
  try { controlPoller.stop(); } catch {}
151
159
  // The cloud session is left as it is: a turn in flight keeps running
@@ -162,7 +170,7 @@ async function main() {
162
170
  });
163
171
 
164
172
  dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
165
- heartbeat.startHeartbeat();
173
+ keepalive.start();
166
174
  wsLink.start();
167
175
  // Not awaited: deliveries wait for it inside the runner, and a relay that
168
176
  // cannot say which cloud session this is leaves the binding to the first
@@ -2,6 +2,7 @@ import { createSdkMcpServer, query } from '@anthropic-ai/claude-agent-sdk';
2
2
 
3
3
  import { formatClaudeBackgroundCommandLimitLabel } from '../../shared/claude-background-command-limit.mjs';
4
4
  import { parseThinkingDisplay } from '../../shared/claude-thinking.mjs';
5
+ import { PERSON_ONLY_QUESTION_SOURCE } from '../../shared/relay-asks.mjs';
5
6
  import { MEDIA_EMBED_INSTRUCTION_TEXT } from '../../shared/media-embed-instructions.mjs';
6
7
  import { REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS } from '../../shared/remote-relay-tool-core.mjs';
7
8
  import { createPreviewToolDefinition } from './claude-preview-tool.mjs';
@@ -631,11 +632,151 @@ export async function readPlanUsage(turn, dbg = () => {}, { timeoutMs = 10000 }
631
632
  }
632
633
  }
633
634
 
635
+ // Asks Claude Code marks as person-only: a removal of a workspace or system
636
+ // directory, a target it cannot resolve. No rule, mode or automatic approval
637
+ // may answer them, so allow-all parity stops here. The SDK does not forward
638
+ // the wire's `classifier_approvable`, so the safety-check kinds count only
639
+ // together with `suppressAlwaysAllowRule`; a sensitive-file safety check
640
+ // (an edit under .git/ or .claude/) carries no such flag and stays allowed.
641
+ // A user's `permissions.ask` rule (`matchedAskRule`) changes nothing: the
642
+ // flags decide, and an ask that rule forced without them stays allowed.
643
+ const PERSON_ONLY_REASON_TYPES = new Set(['safetyCheck', 'subcommandResults']);
644
+ export const PERSON_ONLY_ALLOW_CHOICE = 'Allow once';
645
+ export const PERSON_ONLY_DENY_CHOICE = 'Deny';
646
+ export { PERSON_ONLY_QUESTION_SOURCE };
647
+ // The card's first line is fixed text: the relay's console log prints the
648
+ // first 60 characters of a question, and the command must not reach it.
649
+ const PERSON_ONLY_QUESTION_LEAD = 'Claude Code marks this as person-only: no rule or automatic approval may allow it. Allow it once?';
650
+ const PERSON_ONLY_RATIONALE = 'Claude Code needs a person to approve this before it runs. Nothing runs until you choose.';
651
+ // The card shows the whole command or nothing: a cut could hide the removal.
652
+ export const PERSON_ONLY_COMMAND_MAX = 8000;
653
+ export const PERSON_ONLY_TOO_LONG_MESSAGE = 'Not run: too long to show for approval; issue the removal as a short command of its own.';
654
+ const PERSON_ONLY_REASON_MAX = 400;
655
+ const PERSON_ONLY_TYPED_ANSWER_MAX = 500;
656
+
657
+ export function isPersonOnlyAsk(options) {
658
+ if (!options || typeof options !== 'object') return false;
659
+ if (options.defaultToNo === true) return true;
660
+ return PERSON_ONLY_REASON_TYPES.has(options.decisionReasonType)
661
+ && options.suppressAlwaysAllowRule === true;
662
+ }
663
+
664
+ // Characters that hide or reorder text: C0 and C1 controls, bidi controls,
665
+ // zero-width characters and the Unicode line separators. On the card each
666
+ // one shows as a visible \u{XXXX} escape (the body is pre-wrap plain text).
667
+ const INVISIBLE_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g;
668
+
669
+ function escapeInvisible(text) {
670
+ return String(text ?? '').replace(INVISIBLE_CHARACTERS, (char) => (
671
+ `\\u{${char.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}}`
672
+ ));
673
+ }
674
+
675
+ // The CLI's reason may carry ANSI colour (sdk.d.ts): drop well-formed
676
+ // sequences, then escape what is left, newlines included (one line).
677
+ function reasonLine(value) {
678
+ const text = String(value ?? '')
679
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '')
680
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
681
+ .trim();
682
+ return capText(escapeInvisible(text), PERSON_ONLY_REASON_MAX);
683
+ }
684
+
685
+ function capText(text, max) {
686
+ if (text.length <= max) return text;
687
+ return `${text.slice(0, max)}â€Ļ (${text.length - max} more characters)`;
688
+ }
689
+
690
+ function personOnlySubject(input) {
691
+ if (typeof input?.command === 'string') return { label: 'command', text: input.command };
692
+ for (const key of ['file_path', 'notebook_path', 'path']) {
693
+ if (typeof input?.[key] === 'string') return { label: 'path', text: input[key] };
694
+ }
695
+ let json = '';
696
+ try { json = JSON.stringify(input ?? {}); } catch { json = ''; }
697
+ return { label: 'input', text: json };
698
+ }
699
+
700
+ // Each line escaped and behind "│ ", each line break marked "⏎", so no line
701
+ // of the command can pass for the card's own text.
702
+ function subjectLines(text) {
703
+ return String(text ?? '').split('\n').map(escapeInvisible);
704
+ }
705
+
706
+ function subjectTooLong(input) {
707
+ return subjectLines(personOnlySubject(input).text).join('\n').length > PERSON_ONLY_COMMAND_MAX;
708
+ }
709
+
710
+ export function personOnlyQuestionText(toolName, input, decisionReason) {
711
+ const subject = personOnlySubject(input);
712
+ const lines = subjectLines(subject.text);
713
+ const parts = [PERSON_ONLY_QUESTION_LEAD];
714
+ const reason = reasonLine(decisionReason);
715
+ if (reason) parts.push(`Reason: ${reason}`);
716
+ const count = lines.length > 1 ? `, ${lines.length} lines` : '';
717
+ parts.push(`${toolName || 'Tool'} ${subject.label}${count}:\n│ ${lines.join(' ⏎\n│ ')}`);
718
+ return parts.join('\n\n');
719
+ }
720
+
721
+ function personOnlyDenyMessage(decisionReason, why) {
722
+ const reason = reasonLine(decisionReason);
723
+ return `Not run: Claude Code marks this as person-only${reason ? ` (${reason})` : ''}, and ${why}. `
724
+ + 'Do not work around it by splitting, scripting or re-issuing it through another tool.';
725
+ }
726
+
727
+ // Allow/Deny card over the question bridge, no typed replies. Only the exact
728
+ // "Allow once" allows, and never with `updatedPermissions`: the next such ask
729
+ // asks again.
730
+ async function askPersonOnly({ askUserBridge, toolName, input, options, signal, dbg }) {
731
+ const reason = options?.decisionReason;
732
+ const kind = options?.decisionReasonType || 'defaultToNo';
733
+ const deny = (why, outcome) => {
734
+ dbg('person-only ask', toolName, kind, outcome);
735
+ return { behavior: 'deny', message: personOnlyDenyMessage(reason, why) };
736
+ };
737
+ if (signal?.aborted) return deny('the turn was stopped', 'aborted');
738
+ if (subjectTooLong(input)) {
739
+ dbg('person-only ask', toolName, kind, 'too-long');
740
+ return { behavior: 'deny', message: PERSON_ONLY_TOO_LONG_MESSAGE };
741
+ }
742
+ if (!askUserBridge) return deny('the relay could not show the question', 'no-bridge');
743
+ const question = personOnlyQuestionText(toolName, input, reason);
744
+ let result;
745
+ try {
746
+ result = await askUserBridge.handleAskUserQuestion({
747
+ questions: [{
748
+ question,
749
+ header: 'Safety check',
750
+ options: [{ label: PERSON_ONLY_ALLOW_CHOICE }, { label: PERSON_ONLY_DENY_CHOICE }],
751
+ }],
752
+ }, {
753
+ signal,
754
+ source: PERSON_ONLY_QUESTION_SOURCE,
755
+ rationale: PERSON_ONLY_RATIONALE,
756
+ allowFreeform: false,
757
+ });
758
+ } catch (error) {
759
+ if (signal?.aborted) return deny('the turn was stopped', 'aborted');
760
+ dbg('person-only ask bridge failed', error?.message || String(error));
761
+ return deny('the relay could not show the question', 'bridge-failed');
762
+ }
763
+ if (signal?.aborted) return deny('the turn was stopped', 'aborted');
764
+ if (result?.timedOut) return deny('no answer came before the timeout', 'timed-out');
765
+ const answer = String(result?.answers?.[question] ?? '').trim();
766
+ if (answer === PERSON_ONLY_ALLOW_CHOICE) {
767
+ dbg('person-only ask', toolName, kind, 'allowed');
768
+ return { behavior: 'allow', updatedInput: input };
769
+ }
770
+ if (!answer || answer === PERSON_ONLY_DENY_CHOICE) return deny('the user denied it', 'denied');
771
+ return deny(`the user denied it: ${capText(escapeInvisible(answer), PERSON_ONLY_TYPED_ANSWER_MAX)}`, 'denied-other');
772
+ }
773
+
634
774
  /**
635
775
  * Build the `canUseTool` callback for one turn. Everything is auto-allowed
636
- * (parity with the Copilot workers' `--allow-all` posture); the only
637
- * interceptions are AskUserQuestion (bridged to relay question cards) and
638
- * ExitPlanMode (surfaces the plan_ready board).
776
+ * (parity with the Copilot workers' `--allow-all` posture) except the asks
777
+ * Claude Code marks as person-only (an Allow/Deny relay card, see
778
+ * isPersonOnlyAsk); the other interceptions are AskUserQuestion (bridged to
779
+ * relay question cards) and ExitPlanMode (surfaces the plan_ready board).
639
780
  *
640
781
  * ExitPlanMode is DENIED after the board is posted: allowing it tells the CLI
641
782
  * the user approved the plan, and the same turn rolls straight into
@@ -658,8 +799,12 @@ export function createCanUseTool({
658
799
  onExitPlanMode,
659
800
  dbg = () => {},
660
801
  } = {}) {
661
- return async function canUseTool(toolName, input, { signal } = {}) {
802
+ return async function canUseTool(toolName, input, options = {}) {
803
+ const signal = options?.signal;
662
804
  const normalizedToolName = String(toolName || '').trim();
805
+ if (isPersonOnlyAsk(options)) {
806
+ return askPersonOnly({ askUserBridge, toolName: normalizedToolName, input, options, signal, dbg });
807
+ }
663
808
  try {
664
809
  if (normalizedToolName === 'AskUserQuestion' && askUserBridge) {
665
810
  const { answers } = await askUserBridge.handleAskUserQuestion(input, { signal });
@@ -18,6 +18,7 @@ import { createClaudeSessionRootResolver } from '../services/claude-session-root
18
18
  import {
19
19
  startClaudeSession,
20
20
  createCanUseTool,
21
+ isPersonOnlyAsk,
21
22
  readContextUsage,
22
23
  readPlanUsage,
23
24
  normalizeClaudeEffort,
@@ -2354,9 +2355,10 @@ export function createClaudeSessionRunner({
2354
2355
  // nothing else is left: the cap exists to bound a retry storm's chatter,
2355
2356
  // while a structured entry carries transcript geometry no later line can
2356
2357
  // restore (a compaction boundary buffered between turns, then 50 task
2357
- // notifications, used to lose its break row silently). The client's
2358
- // capRelayActivityEntries protects structured rows too, but head-caps the
2359
- // prose it keeps; here the newest lines are the informative ones.
2358
+ // notifications, used to lose its break row silently). The relay's
2359
+ // capRelayActivityEntries protects structured rows too and keeps the
2360
+ // newest prose of each thread; here too the newest lines are the
2361
+ // informative ones.
2360
2362
  while (proc.pendingActivities.length > MAX_PENDING_ACTIVITIES) {
2361
2363
  const proseIndex = proc.pendingActivities.findIndex((carried) => typeof carried === 'string');
2362
2364
  proc.pendingActivities.splice(proseIndex === -1 ? 0 : proseIndex, 1);
@@ -3806,8 +3808,9 @@ export function createClaudeSessionRunner({
3806
3808
  // denied. Register a continuation turn first: it is born processing,
3807
3809
  // gives the card a real queue row, and the flow's eventual top-level
3808
3810
  // result (or stream end) closes it like any other continuation.
3811
+ // A person-only safety ask is a question card too.
3809
3812
  if (
3810
- toolName === 'AskUserQuestion'
3813
+ (toolName === 'AskUserQuestion' || isPersonOnlyAsk(options))
3811
3814
  && proc === processRef
3812
3815
  && !processRef.activeCtx
3813
3816
  && !processRef.pendingDelivered.length
@@ -4582,6 +4585,8 @@ export function createClaudeSessionRunner({
4582
4585
  backgroundTaskIds,
4583
4586
  resyncBackgroundTasks,
4584
4587
  flushKeptTaskSettles,
4588
+ // Settle notices still kept for the relay: the keepalive stays busy.
4589
+ keptTaskSettleCount: () => keptTaskSettles.size(),
4585
4590
  shutdown,
4586
4591
  // Test seams / observability.
4587
4592
  _getProcess: () => proc,
@@ -14,6 +14,7 @@ import {
14
14
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
15
15
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
16
16
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
17
+ import { createWorkerKeepalive } from '../../shared/worker-runtime/worker-keepalive.mjs';
17
18
  import { resolveBuildId } from '../../shared/build-id.mjs';
18
19
  import { createControlPoller } from '../../shared/control-poller.mjs';
19
20
  import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
@@ -174,10 +175,16 @@ export async function runClaudeSessionWorker({
174
175
  getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
175
176
  onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
176
177
  onAnswered: () => turnRunner.flushKeptTaskSettles(),
178
+ getKeptPostCount: () => turnRunner.keptTaskSettleCount?.() || 0,
177
179
  });
178
180
 
181
+ // One timer for the heartbeat and the socket's readiness refresh: 10 s
182
+ // while working, 30 s while idle on a relay that allows it.
183
+ let keepalive = null;
179
184
  const wsLink = createWorkerWebSocketLink({
180
185
  serverUrl,
186
+ readyRefreshMs: 0,
187
+ onRelayFrame: () => keepalive?.nudge(),
181
188
  token,
182
189
  dbg,
183
190
  getSessionReady: () => true,
@@ -239,6 +246,7 @@ export async function runClaudeSessionWorker({
239
246
  dbg('unknown worker control ignored', type || '(none)');
240
247
  },
241
248
  });
249
+ keepalive = createWorkerKeepalive({ heartbeat, link: wsLink, busyMs: HEARTBEAT_MS });
242
250
  linkBridge.attach(wsLink);
243
251
 
244
252
  let shuttingDown = false;
@@ -247,6 +255,7 @@ export async function runClaudeSessionWorker({
247
255
  shuttingDown = true;
248
256
  dbg(`shutting down (${signal})`);
249
257
  try { wsLink.stop(); } catch {}
258
+ try { keepalive.stop(); } catch {}
250
259
  try { heartbeat.stopHeartbeat(); } catch {}
251
260
  try { controlPoller.stop(); } catch {}
252
261
  // Consumed steers still settling must fail, not be left for dead-worker
@@ -271,6 +280,6 @@ export async function runClaudeSessionWorker({
271
280
  });
272
281
 
273
282
  dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}${backend ? ` backend=${backend.id}` : ''} build=${buildId || 'unknown'}`);
274
- heartbeat.startHeartbeat();
283
+ keepalive.start();
275
284
  wsLink.start();
276
285
  }
@@ -5133,6 +5133,7 @@ export function createCopilotSdkSessionRunner({
5133
5133
  resyncBackgroundTasks,
5134
5134
  // The relay answered a heartbeat: settle notices kept for it go out.
5135
5135
  flushKeptTaskSettles: () => keptTaskSettles.flush(),
5136
+ keptTaskSettleCount: () => keptTaskSettles.size(),
5136
5137
  cancelPushedMessage,
5137
5138
  // The task panel's per-card Stop (`rpc.tasks.cancel`); also what a
5138
5139
  // subagent lane's Stop tries — the runtime tracks sync agents in the same
@@ -20,6 +20,7 @@ import {
20
20
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
21
21
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
22
22
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
23
+ import { createWorkerKeepalive } from '../../shared/worker-runtime/worker-keepalive.mjs';
23
24
  import { resolveBuildId } from '../../shared/build-id.mjs';
24
25
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
25
26
  import {
@@ -158,10 +159,16 @@ async function main() {
158
159
  onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
159
160
  // The relay answered: settle notices it missed go out.
160
161
  onAnswered: () => turnRunner.flushKeptTaskSettles?.(),
162
+ getKeptPostCount: () => turnRunner.keptTaskSettleCount?.() || 0,
161
163
  });
162
164
 
165
+ // One timer for the heartbeat and the socket's readiness refresh: 10 s
166
+ // while working, 30 s while idle on a relay that allows it.
167
+ let keepalive = null;
163
168
  const wsLink = createWorkerWebSocketLink({
164
169
  serverUrl,
170
+ readyRefreshMs: 0,
171
+ onRelayFrame: () => keepalive?.nudge(),
165
172
  token,
166
173
  dbg,
167
174
  buildId,
@@ -213,11 +220,13 @@ async function main() {
213
220
  dbg('worker control ignored (unsupported by the SDK worker)', type || '(none)');
214
221
  },
215
222
  });
223
+ keepalive = createWorkerKeepalive({ heartbeat, link: wsLink, busyMs: HEARTBEAT_MS });
216
224
  linkBridge.attach(wsLink);
217
225
 
218
226
  const shutdown = async (signal) => {
219
227
  dbg(`shutting down (${signal})`);
220
228
  try { wsLink.stop(); } catch {}
229
+ try { keepalive.stop(); } catch {}
221
230
  try { heartbeat.stopHeartbeat(); } catch {}
222
231
  try { controlPoller.stop(); } catch {}
223
232
  // Rows whose prompt the runtime consumed and whose settle marker has not
@@ -246,7 +255,7 @@ async function main() {
246
255
  });
247
256
 
248
257
  dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
249
- heartbeat.startHeartbeat();
258
+ keepalive.start();
250
259
  wsLink.start();
251
260
  }
252
261
 
@@ -13,6 +13,7 @@ import {
13
13
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
14
14
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
15
15
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
16
+ import { createWorkerKeepalive } from '../../shared/worker-runtime/worker-keepalive.mjs';
16
17
  import { resolveBuildId } from '../../shared/build-id.mjs';
17
18
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
18
19
  import { createControlPoller } from '../../shared/control-poller.mjs';
@@ -110,8 +111,13 @@ async function main() {
110
111
  getSteeringState: () => buildCursorSteeringState(turnRunner),
111
112
  });
112
113
 
114
+ // One timer for the heartbeat and the socket's readiness refresh: 10 s
115
+ // while working, 30 s while idle on a relay that allows it.
116
+ let keepalive = null;
113
117
  const wsLink = createWorkerWebSocketLink({
114
118
  serverUrl,
119
+ readyRefreshMs: 0,
120
+ onRelayFrame: () => keepalive?.nudge(),
115
121
  token,
116
122
  dbg,
117
123
  buildId,
@@ -128,10 +134,12 @@ async function main() {
128
134
  }
129
135
  },
130
136
  });
137
+ keepalive = createWorkerKeepalive({ heartbeat, link: wsLink, busyMs: HEARTBEAT_MS });
131
138
 
132
139
  const shutdown = async (signal) => {
133
140
  dbg(`shutting down (${signal})`);
134
141
  try { wsLink.stop(); } catch {}
142
+ try { keepalive.stop(); } catch {}
135
143
  try { heartbeat.stopHeartbeat(); } catch {}
136
144
  try { controlPoller.stop(); } catch {}
137
145
  // The agent handle owns a SQLite store; close it before exiting — but
@@ -158,7 +166,7 @@ async function main() {
158
166
  });
159
167
 
160
168
  dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
161
- heartbeat.startHeartbeat();
169
+ keepalive.start();
162
170
  wsLink.start();
163
171
  }
164
172