@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.
- package/CHANGELOG.md +113 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +9 -4
- package/server/claude-worker/claude-session-worker-main.mjs +10 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
- package/server/cursor-worker/cursor-session-worker.mjs +9 -1
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +1 -0
- package/server/grok-worker/grok-session-worker.mjs +10 -1
- package/server/grok-worker/grok-turn-runner.mjs +1 -0
- package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
- package/server/migrations/0038-turn-seq.mjs +92 -0
- package/server/public/app/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +7 -0
- package/server/public/app/ask-user-view.js +4 -3
- package/server/public/app/bootstrap.js +62 -4
- package/server/public/app/chat-actions-menu-state.mjs +2 -0
- package/server/public/app/context-usage-view.mjs +68 -0
- package/server/public/app/conversation-view.js +306 -49
- package/server/public/app/header-priority-actions.mjs +5 -1
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/inbox-ui.js +70 -79
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +17 -0
- package/server/public/app/live-activity-cap.mjs +103 -0
- package/server/public/app/questions-panel-ui.js +634 -0
- package/server/public/app/questions-stub-view.mjs +40 -0
- package/server/public/app/questions-view.mjs +89 -0
- package/server/public/app/relay-board-view.js +12 -2
- package/server/public/app/relay-thoughts.mjs +15 -1
- package/server/public/app/session-history-ui.js +1 -0
- package/server/public/app/settings-modal.js +6 -0
- package/server/public/app/socket-handlers.js +42 -11
- package/server/public/app/stream-view-preference.mjs +60 -0
- package/server/public/app/subagent-fold.mjs +6 -3
- package/server/public/app/subagent-timeline-view.mjs +12 -5
- package/server/public/app/subagent-viewer.mjs +8 -6
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -0
- package/server/public/index.html +391 -214
- package/server/repositories/message-repository.mjs +10 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/messages-routes.mjs +35 -14
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +76 -5
- package/server/server-runtime.mjs +178 -131
- package/server/services/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/idle-timers.mjs +184 -0
- package/server/services/queue-wake-hook.mjs +145 -0
- package/server/services/queue-waker.mjs +127 -0
- package/server/services/relay-ask-service.mjs +64 -5
- package/server/services/relay-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +23 -4
- package/server/services/session-history-refresh-service.mjs +4 -0
- package/server/services/session-worker-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +143 -15
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +26 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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)
|
|
637
|
-
*
|
|
638
|
-
*
|
|
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,
|
|
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
|
|
2358
|
-
// capRelayActivityEntries protects structured rows too
|
|
2359
|
-
// prose
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
+
keepalive.start();
|
|
162
170
|
wsLink.start();
|
|
163
171
|
}
|
|
164
172
|
|