@oar-sh/oar 0.9.20 → 0.9.21

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 (95) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-session-process.mjs +7 -0
  9. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +74 -1
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  12. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  13. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  14. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  15. package/server/grok-worker/grok-session-worker.mjs +12 -1
  16. package/server/grok-worker/grok-turn-runner.mjs +3 -0
  17. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  18. package/server/public/app/about-ui.js +1 -1
  19. package/server/public/app/about-view.mjs +19 -0
  20. package/server/public/app/action-confirmations.js +1 -1
  21. package/server/public/app/api-client.js +4 -2
  22. package/server/public/app/ask-user-view.js +20 -6
  23. package/server/public/app/attachments-view.js +1 -1
  24. package/server/public/app/back-guard.mjs +31 -5
  25. package/server/public/app/bootstrap.js +556 -68
  26. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  27. package/server/public/app/chat-header-meta.mjs +27 -0
  28. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  29. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  30. package/server/public/app/composer-settings-label.mjs +90 -0
  31. package/server/public/app/composer-settings-ui.js +442 -0
  32. package/server/public/app/context-menu.mjs +36 -5
  33. package/server/public/app/conversation-deep-link.mjs +15 -11
  34. package/server/public/app/conversation-group-drag.mjs +12 -2
  35. package/server/public/app/conversation-group-order.mjs +9 -2
  36. package/server/public/app/conversation-group-state.mjs +47 -10
  37. package/server/public/app/conversation-origin-ui.js +17 -1
  38. package/server/public/app/conversation-view.js +3 -0
  39. package/server/public/app/cwd-menu-placement.mjs +18 -6
  40. package/server/public/app/cwd-picker.js +36 -6
  41. package/server/public/app/emoji-view.js +1 -1
  42. package/server/public/app/font-scaling.js +26 -0
  43. package/server/public/app/header-priority-actions.mjs +234 -0
  44. package/server/public/app/hidden-messages-view.mjs +4 -0
  45. package/server/public/app/inbox-ui.js +18 -2
  46. package/server/public/app/journal-view.js +311 -58
  47. package/server/public/app/phone-landscape-ui.js +225 -0
  48. package/server/public/app/phone-landscape.mjs +99 -0
  49. package/server/public/app/phone-typing-state.mjs +104 -0
  50. package/server/public/app/pinned-messages-view.mjs +5 -0
  51. package/server/public/app/push-settings.js +2 -2
  52. package/server/public/app/pwa-install.js +24 -3
  53. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  54. package/server/public/app/relay-event-view.mjs +38 -0
  55. package/server/public/app/relay-restart-ui.js +2 -2
  56. package/server/public/app/relay-schedule-view.mjs +32 -6
  57. package/server/public/app/relay-schedules-ui.js +69 -20
  58. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  59. package/server/public/app/session-history-ui.js +250 -0
  60. package/server/public/app/session-history.mjs +599 -0
  61. package/server/public/app/settings-modal.js +61 -6
  62. package/server/public/app/settings-tabs.js +12 -0
  63. package/server/public/app/share-state.js +1 -1
  64. package/server/public/app/shared-conversations-settings.js +2 -2
  65. package/server/public/app/socket-handlers.js +5 -2
  66. package/server/public/app/status-view.mjs +18 -2
  67. package/server/public/app/store.js +87 -16
  68. package/server/public/app/subagent-viewer.mjs +27 -2
  69. package/server/public/app/tab-strip-fade.mjs +101 -0
  70. package/server/public/app/tap-actions.js +66 -0
  71. package/server/public/app/usage-limit-ui.js +63 -2
  72. package/server/public/app/viewer-history.mjs +13 -2
  73. package/server/public/index.html +2249 -1590
  74. package/server/relay-tools.md +1 -0
  75. package/server/repositories/message-repository.mjs +23 -0
  76. package/server/repositories/session-repository.mjs +19 -0
  77. package/server/routes/claude-cloud-routes.mjs +19 -0
  78. package/server/routes/messages-routes.mjs +57 -1
  79. package/server/routes/sessions-routes.mjs +41 -7
  80. package/server/server-runtime.mjs +190 -40
  81. package/server/services/about-service.mjs +22 -53
  82. package/server/services/claude-billing-swap-service.mjs +7 -20
  83. package/server/services/claude-cloud-session-service.mjs +47 -5
  84. package/server/services/remote-relay-dispatcher.mjs +61 -14
  85. package/server/services/session-activity-tracker.mjs +1 -1
  86. package/server/services/session-launch-mutex-service.mjs +23 -0
  87. package/server/services/session-worker-registry-service.mjs +36 -0
  88. package/server/services/session-worker-websocket-service.mjs +40 -16
  89. package/server/services/worker-recycle-service.mjs +772 -0
  90. package/server/services/worker-replacement.mjs +197 -0
  91. package/shared/build-id.mjs +128 -0
  92. package/shared/worker-runtime/heartbeat.mjs +5 -0
  93. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  94. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  95. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Replacing a session's worker: the stop and the launch that the Claude
3
+ * billing swap (claude-billing-swap-service.mjs) and the worker recycle
4
+ * (worker-recycle-service.mjs) share, and the predicate both use to tell
5
+ * whether a worker may be replaced now.
6
+ *
7
+ * The stop is the kill route's: nothing may start the session while it is
8
+ * stopped, every process of the session must be gone before it counts as
9
+ * stopped, and only then is the registry entry dropped. The launch never
10
+ * reuses a live process, so two CLIs never share a session's transcript.
11
+ * The fence keeps the deliveries away from the process being replaced for the
12
+ * whole of it (createWorkerReplacementFence).
13
+ */
14
+
15
+ import { stopSessionWorkerProcesses as defaultStopSessionWorkerProcesses } from './session-worker-stop-service.mjs';
16
+
17
+ /**
18
+ * What keeps a worker from being replaced now: null when nothing does, else
19
+ * 'turn', 'question', 'compaction' or 'background'. From the steering
20
+ * snapshot its heartbeat sends; a worker too old to say `releasable` is read
21
+ * from the rest of it.
22
+ */
23
+ export function workerReleaseHold(worker) {
24
+ if (!worker) return null;
25
+ const steering = worker.steering || null;
26
+ if (steering && typeof steering.releasable === 'boolean') {
27
+ return steering.releasable ? null : (steering.releaseHold || 'turn');
28
+ }
29
+ if (steering) {
30
+ if (steering.holdReason === 'question') return 'question';
31
+ if (steering.holdReason === 'compaction') return 'compaction';
32
+ if (steering.turnActive) return 'turn';
33
+ if (steering.autoCompactWindow?.backgroundWork) return 'background';
34
+ return null;
35
+ }
36
+ return String(worker.status || '') === 'processing' ? 'turn' : null;
37
+ }
38
+
39
+ function toFencePid(value) {
40
+ const pid = Number(value);
41
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
42
+ }
43
+
44
+ /**
45
+ * The replacement fence: while a worker process is being replaced, no work
46
+ * is handed to it.
47
+ *
48
+ * The killed marker the stop sets does not do this. It lapses after the
49
+ * supervisor's short kill grace (a stop that verifies every process gone can
50
+ * take longer), and any path that relaunches a session lifts it at once
51
+ * (`clearRestartSchedule({ resetKilledMarker: true })`, the delivery's own
52
+ * fallback restart among them), after which a delivery would start a turn on
53
+ * the very process being killed. The fence is its own structure, so nothing
54
+ * that resets the supervisor's lifecycle state touches it.
55
+ *
56
+ * Keyed by session and the pid being replaced: the new worker has another
57
+ * pid and is never fenced, so it takes the work that waited meanwhile. A
58
+ * request that names no pid cannot be told from the old process and is
59
+ * refused while the session's fence is up.
60
+ *
61
+ * - `hold(sid, pid)`: raise the fence for that process of the session.
62
+ * - `isHeld(sid, pid)`: true while the fence is up for that pid (or for any
63
+ * pid of the session, when `pid` is unknown).
64
+ * - `release(sid, pid?)`: lower it; with a pid, only if that is the fenced
65
+ * one, so a late release from an older replacement never lifts a newer one.
66
+ */
67
+ export function createWorkerReplacementFence() {
68
+ const held = new Map();
69
+
70
+ function key(sid) {
71
+ return String(sid || '').trim();
72
+ }
73
+
74
+ function hold(sid, pid) {
75
+ const sessionId = key(sid);
76
+ if (!sessionId) return false;
77
+ held.set(sessionId, toFencePid(pid));
78
+ return true;
79
+ }
80
+
81
+ function isHeld(sid, pid = null) {
82
+ const sessionId = key(sid);
83
+ if (!sessionId || !held.has(sessionId)) return false;
84
+ const fencedPid = held.get(sessionId);
85
+ const askingPid = toFencePid(pid);
86
+ // An unknown pid on either side: the old process cannot be ruled out.
87
+ if (fencedPid === null || askingPid === null) return true;
88
+ return fencedPid === askingPid;
89
+ }
90
+
91
+ function release(sid, pid = undefined) {
92
+ const sessionId = key(sid);
93
+ if (!sessionId || !held.has(sessionId)) return false;
94
+ if (pid !== undefined && toFencePid(pid) !== held.get(sessionId)) return false;
95
+ held.delete(sessionId);
96
+ return true;
97
+ }
98
+
99
+ function list() {
100
+ return Array.from(held, ([sdkSessionId, pid]) => ({ sdkSessionId, pid }));
101
+ }
102
+
103
+ return { hold, isHeld, release, list };
104
+ }
105
+
106
+ export function createWorkerReplacement({
107
+ // The session worker registry: getWorker(sid), removeWorker(sid).
108
+ registry,
109
+ // The session worker supervisor: markKilled, cancelPendingStart,
110
+ // clearRestartSchedule, resetHealth, ensureWorker.
111
+ supervisor,
112
+ // Handed to stopSessionWorkerProcesses to find every process of a session.
113
+ processInspector = null,
114
+ // session-worker-stop-service.mjs by default; tests inject a fake so they
115
+ // need no processes: async ({ sdkSessionId, worker, processInspector,
116
+ // isPidAliveImpl, killTmuxSessionImpl }) → { ok, error?, remainingPids? }.
117
+ stopSessionWorkerProcesses = defaultStopSessionWorkerProcesses,
118
+ isProcessAlive = undefined,
119
+ killTmuxSession = undefined,
120
+ logger = console,
121
+ // The caller's tag for its warnings ('BILLING SWAP', 'WORKER RECYCLE'), and
122
+ // a function for the runtime's own log prefix, or a fixed string.
123
+ logPrefix = '',
124
+ // The replacement fence the caller raises before the stop and lowers once
125
+ // the launch is done (createWorkerReplacementFence). Passed in so the
126
+ // runtime's replacements and its delivery paths share one; this module
127
+ // only hands it back.
128
+ fence = createWorkerReplacementFence(),
129
+ } = {}) {
130
+ if (typeof stopSessionWorkerProcesses !== 'function') {
131
+ throw new Error('createWorkerReplacement requires stopSessionWorkerProcesses');
132
+ }
133
+
134
+ function prefix() {
135
+ try {
136
+ return typeof logPrefix === 'function' ? String(logPrefix() || '') : String(logPrefix || '');
137
+ } catch {
138
+ return '';
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Stop every process of the session. `{ ok: true }` once they are all gone
144
+ * and the registry and the supervisor's health state are cleared; else
145
+ * `{ ok: false, error, remainingPids }`, and the caller retries with a
146
+ * backoff.
147
+ *
148
+ * `keepKilled: true` is for a session that must stay stopped (an archived
149
+ * conversation): the killed marker set here is left in place on either
150
+ * outcome, as the archive route leaves it, so nothing relaunches the
151
+ * session meanwhile.
152
+ */
153
+ async function stopWorker(sid, { keepKilled = false } = {}) {
154
+ const worker = registry?.getWorker?.(sid) || null;
155
+ // Nothing may start the session while it is being stopped: a launch then
156
+ // would adopt the old process, kind and build and all.
157
+ supervisor?.markKilled?.(sid);
158
+ // A stop that fails backs off (the caller retries it). Unless the session
159
+ // is to stay stopped, the killed marker goes with it, so the session's
160
+ // worker, alive or relaunched, keeps asking for work meanwhile.
161
+ const backOff = (error, remainingPids = []) => {
162
+ if (!keepKilled) supervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
163
+ const remaining = remainingPids.length ? ` remaining=${remainingPids.join(',')}` : '';
164
+ const marker = keepKilled ? 'kill marker kept' : 'kill marker cleared';
165
+ try {
166
+ logger.warn?.(`${prefix()}STOP FAILED session=${String(sid).slice(0, 8)} ${error}${remaining} — ${marker}, retrying with a backoff`);
167
+ } catch {}
168
+ return { ok: false, error, remainingPids };
169
+ };
170
+ let stopped = null;
171
+ try {
172
+ await supervisor?.cancelPendingStart?.(sid, { wait: true });
173
+ stopped = await stopSessionWorkerProcesses({
174
+ sdkSessionId: sid,
175
+ worker,
176
+ processInspector,
177
+ isPidAliveImpl: isProcessAlive,
178
+ killTmuxSessionImpl: killTmuxSession,
179
+ });
180
+ } catch (error) {
181
+ return backOff(String(error?.message || error || 'worker-stop-failed'));
182
+ }
183
+ if (!stopped?.ok) return backOff(stopped?.error || 'worker-survived-stop', stopped?.remainingPids || []);
184
+ registry?.removeWorker?.(sid);
185
+ supervisor?.resetHealth?.(sid, { clearFailureCount: false });
186
+ return { ok: true };
187
+ }
188
+
189
+ /** Launch the session's worker, never reusing a live process: `{ ok, error }`. */
190
+ async function launchWorker(sid) {
191
+ supervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
192
+ const launched = await supervisor?.ensureWorker?.(sid, { allowProcessReuse: false });
193
+ return { ok: launched?.ok === true, error: launched?.error || null };
194
+ }
195
+
196
+ return { stopWorker, launchWorker, fence };
197
+ }
@@ -0,0 +1,128 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // The build id names the code a process runs, so the relay can tell a session
5
+ // worker that started on older code from one that runs what the relay runs.
6
+ //
7
+ // It is `${version}+${commit.slice(0, 12)}` when both are known, the version
8
+ // alone when there is no commit, and null when there is no version either. The
9
+ // commit is part of it because on a git checkout a deploy is a new
10
+ // commit at the same version; on an npm install without a `gitHead` stamp the
11
+ // version alone decides.
12
+ //
13
+ // The relay and every worker compute it once at process start and never read
14
+ // it again, so a tree that moved on under a running process still describes
15
+ // the code that process started with.
16
+ //
17
+ // This module lives in shared/ because both the relay and the workers import
18
+ // it; it depends on nothing beyond node:fs and node:path for the same reason.
19
+
20
+ const COMMIT_PATTERN = /^[0-9a-f]{40}$/i;
21
+ const COMMIT_ID_LENGTH = 12;
22
+
23
+ // The relay stores the build id a worker states, so what it accepts is bounded
24
+ // in length and limited to the characters a version and a commit use.
25
+ const BUILD_ID_MAX_LENGTH = 80;
26
+ const BUILD_ID_PATTERN = /^[A-Za-z0-9+._-]+$/;
27
+
28
+ function readFileText(fsImpl, filePath) {
29
+ try {
30
+ return String(fsImpl.readFileSync(filePath, 'utf8'));
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function readPackageManifest(packageRoot, { fsImpl, pathImpl }) {
37
+ if (!packageRoot) return null;
38
+ try {
39
+ const manifest = JSON.parse(readFileText(fsImpl, pathImpl.join(packageRoot, 'package.json')) || '');
40
+ return manifest && typeof manifest === 'object' ? manifest : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The commit a git checkout has checked out, read from its .git files rather
48
+ * than from a `git` process: HEAD, then the loose ref, then packed-refs. A
49
+ * linked worktree's .git is a file naming its git dir, whose refs live in the
50
+ * common dir. Anything unexpected reads as null. `pathImpl` is the host's path
51
+ * module; tests pass path.posix and path.win32.
52
+ */
53
+ export function readGitCommit(root, { fsImpl = fs, pathImpl = path } = {}) {
54
+ if (!root) return null;
55
+ const dotGit = pathImpl.join(root, '.git');
56
+ let gitDir = dotGit;
57
+ const pointer = readFileText(fsImpl, dotGit);
58
+ if (pointer !== null) {
59
+ const match = pointer.match(/^gitdir:\s*(.+)$/m);
60
+ if (!match) return null;
61
+ gitDir = pathImpl.resolve(root, match[1].trim());
62
+ }
63
+ const head = readFileText(fsImpl, pathImpl.join(gitDir, 'HEAD'));
64
+ if (head === null) return null;
65
+ const headText = head.trim();
66
+ if (COMMIT_PATTERN.test(headText)) return headText.toLowerCase();
67
+ const refMatch = headText.match(/^ref:\s*(\S+)$/);
68
+ if (!refMatch) return null;
69
+ const ref = refMatch[1];
70
+ const commonDirText = readFileText(fsImpl, pathImpl.join(gitDir, 'commondir'));
71
+ const commonDir = commonDirText ? pathImpl.resolve(gitDir, commonDirText.trim()) : gitDir;
72
+ for (const dir of new Set([gitDir, commonDir])) {
73
+ const loose = readFileText(fsImpl, pathImpl.join(dir, ...ref.split('/')));
74
+ if (loose && COMMIT_PATTERN.test(loose.trim())) return loose.trim().toLowerCase();
75
+ }
76
+ const packed = readFileText(fsImpl, pathImpl.join(commonDir, 'packed-refs')) || '';
77
+ for (const line of packed.split(/\r?\n/)) {
78
+ const [hash, name] = line.trim().split(/\s+/);
79
+ if (name === ref && COMMIT_PATTERN.test(hash || '')) return hash.toLowerCase();
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /** The commit this build came from: the checkout's HEAD, else a `gitHead` stamp in package.json. */
85
+ export function resolveBuildCommit(packageRoot, { fsImpl = fs, pathImpl = path } = {}) {
86
+ const fromGit = readGitCommit(packageRoot, { fsImpl, pathImpl });
87
+ if (fromGit) return fromGit;
88
+ const stamp = String(readPackageManifest(packageRoot, { fsImpl, pathImpl })?.gitHead || '').trim();
89
+ return COMMIT_PATTERN.test(stamp) ? stamp.toLowerCase() : null;
90
+ }
91
+
92
+ /** The package.json `version`, or null when the manifest cannot be read or names none. */
93
+ export function readPackageVersion(packageRoot, { fsImpl = fs, pathImpl = path } = {}) {
94
+ const version = readPackageManifest(packageRoot, { fsImpl, pathImpl })?.version;
95
+ return typeof version === 'string' && version.trim() ? version.trim() : null;
96
+ }
97
+
98
+ /**
99
+ * The build id of the tree at `packageRoot`: `{ version, commit, id }`. Call it
100
+ * once at process start and keep the result; see the header for why.
101
+ */
102
+ export function resolveBuildId(packageRoot, { fsImpl = fs, pathImpl = path } = {}) {
103
+ const version = readPackageVersion(packageRoot, { fsImpl, pathImpl });
104
+ const commit = resolveBuildCommit(packageRoot, { fsImpl, pathImpl });
105
+ let id = null;
106
+ if (version && commit) id = `${version}+${commit.slice(0, COMMIT_ID_LENGTH)}`;
107
+ else if (version) id = version;
108
+ return { version, commit, id };
109
+ }
110
+
111
+ /**
112
+ * True only when both ids are known and differ. An unknown id on either side
113
+ * is not evidence of a change, so it never reads as one.
114
+ */
115
+ export function buildIdsDiffer(a, b) {
116
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
117
+ const left = a.trim();
118
+ const right = b.trim();
119
+ return !!left && !!right && left !== right;
120
+ }
121
+
122
+ /** A stated build id, trimmed, or null when it is too long or carries other characters. */
123
+ export function normalizeBuildId(value) {
124
+ if (typeof value !== 'string') return null;
125
+ const text = value.trim();
126
+ if (!text || text.length > BUILD_ID_MAX_LENGTH || !BUILD_ID_PATTERN.test(text)) return null;
127
+ return text;
128
+ }
@@ -37,6 +37,10 @@ export function createHeartbeatController({
37
37
  // relay hands a Claude chat's row only to a worker of the row's billing,
38
38
  // and knows a worker's kind from what it says here and in its hello.
39
39
  workerKind = null,
40
+ // Optional: the build id of the code this worker started on
41
+ // (shared/build-id.mjs). A worker whose websocket is down still beats, so
42
+ // the relay learns the build here as well as from the hello.
43
+ buildId = null,
40
44
  // requestPulse's window: one turn transition can flip the steering
41
45
  // snapshot several times in quick succession, and each flip only needs to
42
46
  // reach the relay once.
@@ -71,6 +75,7 @@ export function createHeartbeatController({
71
75
  ...(settleFailed.length ? { settleFailed } : {}),
72
76
  ...(Array.isArray(backgroundTaskIds) ? { backgroundTasks: backgroundTaskDigest(backgroundTaskIds) } : {}),
73
77
  ...(workerKind ? { workerKind: String(workerKind) } : {}),
78
+ ...(typeof buildId === "string" && buildId.trim() ? { buildId: buildId.trim() } : {}),
74
79
  };
75
80
  const response = await api("POST", "/api/heartbeat", body);
76
81
  // Failed by the relay, or skipped because this worker has no claim on
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The heartbeat steering snapshot of a worker that runs one delivered message
3
+ * at a time and never steers into a running turn: the Cursor, Grok and Claude
4
+ * cloud workers. They send it for the release fields, which tell the relay
5
+ * whether it may stop this worker and launch a fresh one now (its recycle
6
+ * after a deploy), the same fields the Claude and Copilot SDK workers send.
7
+ *
8
+ * `supported: false` keeps the composer as it was for these kinds: it offers
9
+ * mid-turn steering only to a worker that says `supported: true`, and reads
10
+ * neither the hold nor the cancellable ids of one that does not.
11
+ *
12
+ * - `activeAttempt`: the runner's `getActiveQueueAttempt()` (`{ id, attemptId }`
13
+ * or null), the row the running turn owns.
14
+ * - `turnActive`: the runner's own word that a turn runs (`isTurnActive()`),
15
+ * for a turn whose row has no id to show.
16
+ * - `backgroundTaskCount`: live background work that ends with the worker
17
+ * (Grok's terminals).
18
+ *
19
+ * A turn holds the release ('turn'), then live background work ('background').
20
+ */
21
+ export function buildSimpleSteeringState({ activeAttempt = null, turnActive = false, backgroundTaskCount = 0 } = {}) {
22
+ const messageId = String(activeAttempt?.id || '').trim() || null;
23
+ const turn = turnActive === true || Boolean(messageId);
24
+ const tasks = Number(backgroundTaskCount);
25
+ const liveTasks = Number.isInteger(tasks) && tasks > 0 ? tasks : 0;
26
+ const releaseHold = turn ? 'turn' : (liveTasks > 0 ? 'background' : null);
27
+ return {
28
+ turnActive: turn,
29
+ canSteer: false,
30
+ holdReason: null,
31
+ messageId,
32
+ supported: false,
33
+ cancellableIds: [],
34
+ releasable: !releaseHold,
35
+ releaseHold,
36
+ backgroundTaskCount: liveTasks,
37
+ };
38
+ }
@@ -1,3 +1,5 @@
1
+ import { buildIdsDiffer } from "../build-id.mjs";
2
+
1
3
  function normalizeText(value) {
2
4
  const text = String(value || "").trim();
3
5
  return text || null;
@@ -70,6 +72,10 @@ export function createWorkerWebSocketLink({
70
72
  // every hello and ready: the relay hands a Claude chat's row only to a
71
73
  // worker of the row's billing.
72
74
  workerKind = null,
75
+ // The build id of the code this worker started on (shared/build-id.mjs),
76
+ // stated in every hello and ready next to the kind: the relay recycles a
77
+ // worker whose build differs from its own once the worker is idle.
78
+ buildId = null,
73
79
  minBackoffMs = 1000,
74
80
  maxBackoffMs = 8_000,
75
81
  jitterMs = 250,
@@ -121,6 +127,12 @@ export function createWorkerWebSocketLink({
121
127
  let lastPongAt = null;
122
128
  let lastQueueChangedAt = null;
123
129
  let lastDeliveryAt = null;
130
+ // The build id the relay named in its latest server.hello (null when it
131
+ // named none), and whether this connection has already logged that it
132
+ // differs from ours: the relay acks every worker.hello with another
133
+ // server.hello, and the log needs the news only once per connection.
134
+ let relayBuildId = null;
135
+ let buildMismatchLogged = false;
124
136
 
125
137
  function getNowMs() {
126
138
  const value = Number(now());
@@ -175,6 +187,7 @@ export function createWorkerWebSocketLink({
175
187
  sessionId: normalizeText(getSessionId()),
176
188
  pid: normalizePositiveInt(getPid()),
177
189
  ...(normalizeText(workerKind) ? { kind: normalizeText(workerKind) } : {}),
190
+ ...(normalizeText(buildId) ? { buildId: normalizeText(buildId) } : {}),
178
191
  ...(held ? { ready: false } : {}),
179
192
  });
180
193
  if (sent) lastHelloSentAt = getNowMs();
@@ -203,6 +216,7 @@ export function createWorkerWebSocketLink({
203
216
  sessionId: normalizeText(getSessionId()),
204
217
  pid: normalizePositiveInt(getPid()),
205
218
  ...(normalizeText(workerKind) ? { kind: normalizeText(workerKind) } : {}),
219
+ ...(normalizeText(buildId) ? { buildId: normalizeText(buildId) } : {}),
206
220
  });
207
221
  if (sent) lastReadySentAt = getNowMs();
208
222
  return sent;
@@ -393,6 +407,7 @@ export function createWorkerWebSocketLink({
393
407
  clearReadyRefreshTimer();
394
408
  // The next connection may reach a different (restarted) relay.
395
409
  serverCapabilities = new Set();
410
+ buildMismatchLogged = false;
396
411
  }
397
412
  if (stopped || !reconnect) return true;
398
413
  // A newer socket is already under way and owns the reconnect.
@@ -440,6 +455,13 @@ export function createWorkerWebSocketLink({
440
455
  }
441
456
  if (payload?.type === "server.hello") {
442
457
  serverCapabilities = new Set(Array.isArray(payload.capabilities) ? payload.capabilities.map(String) : []);
458
+ relayBuildId = normalizeText(payload.buildId);
459
+ // Only a line in the log: the relay decides when to replace this
460
+ // worker, and the worker takes no action of its own on it.
461
+ if (!buildMismatchLogged && buildIdsDiffer(relayBuildId, normalizeText(buildId))) {
462
+ buildMismatchLogged = true;
463
+ dbg(`relay build ${relayBuildId}, mine ${normalizeText(buildId)}: the relay will recycle this worker when idle`);
464
+ }
443
465
  if (!serverHelloSeen) {
444
466
  serverHelloSeen = true;
445
467
  // The handshake is complete: only now does the backoff start over.
@@ -536,6 +558,7 @@ export function createWorkerWebSocketLink({
536
558
  lastPongAt,
537
559
  lastQueueChangedAt,
538
560
  lastDeliveryAt,
561
+ relayBuildId,
539
562
  };
540
563
  }
541
564
 
@@ -16,7 +16,7 @@ import fs from 'node:fs';
16
16
  export const WORKING_DIRECTORY_MISSING_CODE = 'working-directory-missing';
17
17
 
18
18
  export const WORKING_DIRECTORY_GUIDANCE =
19
- 'Restore the folder, or choose another working directory for this conversation (⋯ → Change CWD), '
19
+ 'Restore the folder, or choose another working directory for this conversation (︙ → Change CWD), '
20
20
  + 'then send the message again.';
21
21
 
22
22
  // What a failed launch says, in the words of the SDKs: Node's spawn error, the