@turns/dev 0.0.30 → 0.0.32

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.
@@ -58,7 +58,7 @@ export class AgentAnchorConnection {
58
58
  socket.once('connect', resolve);
59
59
  socket.once('error', reject);
60
60
  }), timeoutMs, `Agent anchor connection ${this.socketPath}`);
61
- this.snapshot = await this.request('snapshot', {}, timeoutMs);
61
+ this.snapshot = await this.request('snapshot', STATUS_SNAPSHOT, timeoutMs);
62
62
  return this;
63
63
  }
64
64
  handleLine(line) {
@@ -228,6 +228,21 @@ export async function launchAgentAnchor(sessionId, timeoutMs = 15_000) {
228
228
  catch { }
229
229
  throw lastError || new Error(`Timed out starting agent anchor. See ${logPath}`);
230
230
  }
231
+ /** Ask an anchor for its state without its buffered live events. Anchors
232
+ * older than this option ignore it and answer in full, as before. */
233
+ export const STATUS_SNAPSHOT = { liveEvents: false };
234
+ /**
235
+ * Whether a failed connection proves nothing is listening on an anchor socket.
236
+ *
237
+ * Only then may the socket file be removed. A timeout proves the opposite of
238
+ * nothing: a busy daemon or a slow, swapped or heavily loaded anchor answers
239
+ * late, and unlinking the path of an anchor that is alive orphans the agent
240
+ * running behind it. It can never be adopted again, its turn's result is lost,
241
+ * and the next prompt starts a second harness on the same session.
242
+ */
243
+ export function anchorSocketIsAbandoned(error) {
244
+ return ['ECONNREFUSED', 'ENOENT', 'ENOTSOCK'].includes(String(error?.code || ''));
245
+ }
231
246
  export async function connectExistingAgentAnchors() {
232
247
  const directory = agentAnchorDirectory();
233
248
  let paths = [];
@@ -250,8 +265,10 @@ export async function connectExistingAgentAnchors() {
250
265
  }
251
266
  anchors.push({ connection, snapshot });
252
267
  }
253
- catch {
268
+ catch (error) {
254
269
  connection.close();
270
+ if (!anchorSocketIsAbandoned(error))
271
+ return;
255
272
  try {
256
273
  fs.unlinkSync(socketPath);
257
274
  }
@@ -316,7 +333,7 @@ export function waitForAgentTurn(connection, messageId, timeoutMs = 12 * 60 * 60
316
333
  settle(initial);
317
334
  if (!settled)
318
335
  connection
319
- .request('snapshot', {}, 5_000)
336
+ .request('snapshot', STATUS_SNAPSHOT, 5_000)
320
337
  .then((latest) => {
321
338
  const result = resultFromSnapshot(latest);
322
339
  if (result)
@@ -111,6 +111,19 @@ function childResult(child, timeoutMs, harness, stderrTail) {
111
111
  timer.unref?.();
112
112
  return result.promise;
113
113
  }
114
+ /** Run a transport operation, failing fast when its harness process has died. */
115
+ function untilHarnessExit(transport, start) {
116
+ if (transport?.exited)
117
+ return Promise.reject(transport.exited);
118
+ let work;
119
+ try {
120
+ work = Promise.resolve(start());
121
+ }
122
+ catch (error) {
123
+ work = Promise.reject(error);
124
+ }
125
+ return typeof transport?.untilExit === 'function' ? transport.untilExit(work) : work;
126
+ }
114
127
  class LineTransport {
115
128
  backgroundTasks;
116
129
  anchor;
@@ -120,6 +133,9 @@ class LineTransport {
120
133
  lastEventAt;
121
134
  child;
122
135
  stderrTail = new StderrTail();
136
+ /** Why the harness process is gone, once it is. */
137
+ exited = null;
138
+ exitSignal = deferred();
123
139
  constructor(anchor, harness, command, args, cwd, env = {}) {
124
140
  this.anchor = anchor;
125
141
  this.harness = harness;
@@ -150,8 +166,10 @@ class LineTransport {
150
166
  phase: 'queued',
151
167
  });
152
168
  });
153
- this.child.once('close', (code, signal) => {
154
- const error = harnessExitError(anchor.source, code, signal, this.stderrTail.lines());
169
+ const ended = (error) => {
170
+ if (this.exited)
171
+ return;
172
+ this.exited = error;
155
173
  for (const waiter of this.pending.values())
156
174
  waiter.reject(error);
157
175
  this.pending.clear();
@@ -159,7 +177,38 @@ class LineTransport {
159
177
  if (this.anchor.transport === this && this.anchor.status === 'idle')
160
178
  this.anchor.scheduleExit();
161
179
  this.onClose?.(error);
162
- });
180
+ // After the turn's own handling: a result that arrived with the exit wins.
181
+ setImmediate(() => this.exitSignal.resolve(error));
182
+ };
183
+ this.child.once('close', (code, signal) => ended(harnessExitError(anchor.source, code, signal, this.stderrTail.lines())));
184
+ // A process that never started (a working directory deleted since it was
185
+ // checked, no memory, a broken interpreter) reports `error`, not `close`.
186
+ // Unheard, that event is an uncaught exception that takes the anchor down
187
+ // and leaves the browser with "Agent anchor disconnected" for an answer.
188
+ this.child.once('error', (error) => ended(new Error(`${anchor.source} could not be started: ${error?.message || error}`)));
189
+ // A write racing the exit fails with EPIPE; the exit itself is the report.
190
+ this.child.stdin?.on('error', () => { });
191
+ }
192
+ /**
193
+ * Settle with `work`, or fail as soon as the harness process is gone.
194
+ *
195
+ * Requests already waiting when the process exits are rejected there. A
196
+ * request made afterwards writes to a closed pipe, which fails silently, and
197
+ * then waits for a reply that cannot come: a prompt sat "running" for the
198
+ * twelve-hour turn limit, and nothing the browser could send (interrupt,
199
+ * shutdown) got through either.
200
+ */
201
+ untilExit(work) {
202
+ if (this.exited) {
203
+ work.catch(() => { });
204
+ return Promise.reject(this.exited);
205
+ }
206
+ return Promise.race([
207
+ work,
208
+ this.exitSignal.promise.then((error) => {
209
+ throw error;
210
+ }),
211
+ ]);
163
212
  }
164
213
  emit(value, meta = {}) {
165
214
  const raw = JSON.stringify(value);
@@ -1136,6 +1185,9 @@ class RpcTransport extends LineTransport {
1136
1185
  const deadline = Date.now() + timeoutMs;
1137
1186
  while (Date.now() < deadline) {
1138
1187
  await new Promise((resolve) => setTimeout(resolve, 1_000));
1188
+ // A dead process answers no state query; silence is not "idle".
1189
+ if (this.exited)
1190
+ throw this.exited;
1139
1191
  if (this.events === 0 || Date.now() - this.lastEventAt < 2_500)
1140
1192
  continue;
1141
1193
  const state = await this.request('get_state', {}, 20_000).catch(() => null);
@@ -1280,6 +1332,8 @@ class OmpRpcTransport extends RpcTransport {
1280
1332
  this.lastEventAt = Date.now();
1281
1333
  while (Date.now() < deadline) {
1282
1334
  await new Promise((resolve) => setTimeout(resolve, 2_000));
1335
+ if (this.exited)
1336
+ throw this.exited;
1283
1337
  if (Date.now() - this.lastEventAt < 6_000 || this.openTurns > 0)
1284
1338
  continue;
1285
1339
  if (this.eventsSincePrompt === 0 && Date.now() - startedAt < 45_000)
@@ -1515,7 +1569,16 @@ class Anchor {
1515
1569
  this.turnTimeoutMs = 12 * 60 * 60_000;
1516
1570
  this.idleTimer = null;
1517
1571
  }
1518
- snapshot() {
1572
+ /**
1573
+ * Everything a client needs to know about this anchor.
1574
+ *
1575
+ * The buffered live events (up to 2,000 of them, 8 MB) are only wanted by a
1576
+ * daemon recovering a turn it did not watch. Status checks ask for the
1577
+ * snapshot without them: serializing, sending and parsing that buffer every
1578
+ * minute for each running session stalled the daemon's event loop for tens
1579
+ * of milliseconds a time and kept a copy on its heap per anchor.
1580
+ */
1581
+ snapshot({ liveEvents = true } = {}) {
1519
1582
  return {
1520
1583
  protocol: 'turns.agent-anchor.v1',
1521
1584
  owner: 'turns.dev',
@@ -1545,7 +1608,7 @@ class Anchor {
1545
1608
  currentTurn: this.currentTurn ? { ...this.currentTurn } : null,
1546
1609
  pendingInteractions: [...this.pendingInteractions.values()].map((item) => item.interaction),
1547
1610
  lastEventSequence: this.sequence,
1548
- liveEvents: this.events.map((event) => ({ ...event })),
1611
+ liveEvents: liveEvents ? this.events.map((event) => ({ ...event })) : [],
1549
1612
  };
1550
1613
  }
1551
1614
  broadcast(value) {
@@ -1811,32 +1874,32 @@ class Anchor {
1811
1874
  return interruptedBeforePrompt();
1812
1875
  if (this.source === 'claude') {
1813
1876
  if (!(this.transport instanceof ClaudeTransport) ||
1814
- this.transport.child.exitCode !== null ||
1877
+ this.transport.exited || this.transport.child.exitCode !== null ||
1815
1878
  this.transport.sessionId !== this.agentSessionId ||
1816
1879
  this.transport.model !== this.model ||
1817
1880
  this.transport.thinkingLevel !== this.thinkingLevel ||
1818
1881
  this.transport.permissionMode !== this.permissionMode) {
1819
1882
  this.transport?.stop?.();
1820
1883
  this.transport = new ClaudeTransport(this, createSession);
1821
- await this.transport.open();
1884
+ await untilHarnessExit(this.transport, () => this.transport.open());
1822
1885
  }
1823
1886
  if (interruptedBeforePrompt())
1824
1887
  return interruptedBeforePrompt();
1825
- const result = await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
1888
+ const result = await untilHarnessExit(this.transport, () => this.transport.prompt(this.currentTurn.prompt, timeoutMs));
1826
1889
  return { exitCode: 0, signal: null, timedOut: false, error: null, stopReason: result?.subtype || 'completed' };
1827
1890
  }
1828
1891
  if (this.source === 'codex') {
1829
1892
  if (!(this.transport instanceof CodexAppServerTransport) ||
1830
- this.transport.child.exitCode !== null ||
1893
+ this.transport.exited || this.transport.child.exitCode !== null ||
1831
1894
  this.transport.threadId !== this.agentSessionId ||
1832
1895
  this.transport.permissionMode !== this.permissionMode) {
1833
1896
  this.transport?.stop?.();
1834
1897
  this.transport = new CodexAppServerTransport(this);
1835
- await this.transport.open(createSession);
1898
+ await untilHarnessExit(this.transport, () => this.transport.open(createSession));
1836
1899
  }
1837
1900
  if (interruptedBeforePrompt())
1838
1901
  return interruptedBeforePrompt();
1839
- const result = await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
1902
+ const result = await untilHarnessExit(this.transport, () => this.transport.prompt(this.currentTurn.prompt, timeoutMs));
1840
1903
  return {
1841
1904
  exitCode: 0,
1842
1905
  signal: null,
@@ -1847,16 +1910,16 @@ class Anchor {
1847
1910
  }
1848
1911
  if (this.source === 'letta') {
1849
1912
  if (!(this.transport instanceof LettaTransport) ||
1850
- this.transport.child.exitCode !== null ||
1913
+ this.transport.exited || this.transport.child.exitCode !== null ||
1851
1914
  lettaSessionIdentity(this.transport.agentId, this.transport.conversationId) !== this.agentSessionId ||
1852
1915
  this.transport.permissionMode !== this.permissionMode) {
1853
1916
  this.transport?.stop?.();
1854
1917
  this.transport = new LettaTransport(this, createSession);
1855
- await this.transport.open();
1918
+ await untilHarnessExit(this.transport, () => this.transport.open());
1856
1919
  }
1857
1920
  if (interruptedBeforePrompt())
1858
1921
  return interruptedBeforePrompt();
1859
- const result = await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
1922
+ const result = await untilHarnessExit(this.transport, () => this.transport.prompt(this.currentTurn.prompt, timeoutMs));
1860
1923
  return {
1861
1924
  exitCode: 0,
1862
1925
  signal: null,
@@ -1889,8 +1952,12 @@ class Anchor {
1889
1952
  detached: process.platform !== 'win32',
1890
1953
  });
1891
1954
  this.child = child;
1892
- if (invocation.stdin)
1955
+ if (invocation.stdin) {
1956
+ // The harness may exit before reading its prompt; that exit is reported
1957
+ // below, and the broken pipe must not crash the anchor first.
1958
+ child.stdin.on('error', () => { });
1893
1959
  child.stdin.end(`${invocation.stdin}\n`);
1960
+ }
1894
1961
  const stderrTail = new StderrTail();
1895
1962
  for (const [stream, source] of [
1896
1963
  [child.stdout, `${this.source}.stdout`],
@@ -1915,17 +1982,17 @@ class Anchor {
1915
1982
  if (!(this.transport instanceof AcpTransport) ||
1916
1983
  this.transport.harness !== this.source ||
1917
1984
  !this.transport.child ||
1918
- this.transport.child.exitCode !== null ||
1985
+ this.transport.exited || this.transport.child.exitCode !== null ||
1919
1986
  !this.transport.ready ||
1920
1987
  this.transport.sessionId !== this.agentSessionId ||
1921
1988
  this.transport.permissionMode !== this.permissionMode) {
1922
1989
  this.transport?.stop?.();
1923
1990
  this.transport = new AcpTransport(this, this.source, ACP_PROFILES[this.source]);
1924
- await this.transport.open(createSession);
1991
+ await untilHarnessExit(this.transport, () => this.transport.open(createSession));
1925
1992
  }
1926
1993
  if (interruptedBeforePrompt())
1927
1994
  return interruptedBeforePrompt();
1928
- const result = await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
1995
+ const result = await untilHarnessExit(this.transport, () => this.transport.prompt(this.currentTurn.prompt, timeoutMs));
1929
1996
  return { exitCode: 0, signal: null, timedOut: false, error: null, stopReason: result?.stopReason };
1930
1997
  }
1931
1998
  if (RPC_PROFILES[this.source]) {
@@ -1933,27 +2000,27 @@ class Anchor {
1933
2000
  const valid = specializedOmp
1934
2001
  ? this.transport instanceof OmpRpcTransport
1935
2002
  : this.transport instanceof RpcTransport && !(this.transport instanceof OmpRpcTransport);
1936
- if (!valid || this.transport.harness !== this.source || this.transport.child.exitCode !== null) {
2003
+ if (!valid || this.transport.harness !== this.source || this.transport.exited || this.transport.child.exitCode !== null) {
1937
2004
  this.transport?.stop?.();
1938
2005
  this.transport = specializedOmp
1939
2006
  ? new OmpRpcTransport(this, RPC_PROFILES.omp, createSession)
1940
2007
  : new RpcTransport(this, this.source, RPC_PROFILES[this.source], createSession);
1941
2008
  if (specializedOmp)
1942
- await this.transport.open();
2009
+ await untilHarnessExit(this.transport, () => this.transport.open());
1943
2010
  }
1944
2011
  if (interruptedBeforePrompt())
1945
2012
  return interruptedBeforePrompt();
1946
- await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
2013
+ await untilHarnessExit(this.transport, () => this.transport.prompt(this.currentTurn.prompt, timeoutMs));
1947
2014
  return { exitCode: 0, signal: null, timedOut: false, error: null };
1948
2015
  }
1949
2016
  if (this.source === 'deepseek') {
1950
2017
  if (!(this.transport instanceof DeepseekTransport)) {
1951
2018
  this.transport = new DeepseekTransport(this);
1952
- await this.transport.open(createSession);
2019
+ await untilHarnessExit(this.transport, () => this.transport.open(createSession));
1953
2020
  }
1954
2021
  if (interruptedBeforePrompt())
1955
2022
  return interruptedBeforePrompt();
1956
- await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
2023
+ await untilHarnessExit(this.transport, () => this.transport.prompt(this.currentTurn.prompt, timeoutMs));
1957
2024
  return { exitCode: 0, signal: null, timedOut: false, error: null };
1958
2025
  }
1959
2026
  throw new Error(`Turns has no anchor transport for harness "${this.source}".`);
@@ -2015,7 +2082,7 @@ class Anchor {
2015
2082
  this.expirePendingInteractions('The turn was interrupted before this interaction was answered.');
2016
2083
  try {
2017
2084
  if (this.transport?.interrupt)
2018
- await this.transport.interrupt();
2085
+ await untilHarnessExit(this.transport, () => this.transport.interrupt());
2019
2086
  else if (this.child) {
2020
2087
  try {
2021
2088
  process.kill(-this.child.pid, 'SIGINT');
@@ -2046,7 +2113,7 @@ class Anchor {
2046
2113
  await this.interrupt(String(this.currentTurn?.messageId || ''));
2047
2114
  return { steered: true, mode: 'interrupt' };
2048
2115
  }
2049
- await this.transport.steer(prompt, messageId);
2116
+ await untilHarnessExit(this.transport, () => this.transport.steer(prompt, messageId));
2050
2117
  this.emitLive({
2051
2118
  event: 'turn.steer',
2052
2119
  agent: this.source,
@@ -2069,7 +2136,7 @@ class Anchor {
2069
2136
  this.setStatus('running');
2070
2137
  this.emitLive({ event: 'compaction.started', agent: this.source, phase: 'queued' });
2071
2138
  try {
2072
- const result = await this.transport.compact(this.turnTimeoutMs);
2139
+ const result = await untilHarnessExit(this.transport, () => this.transport.compact(this.turnTimeoutMs));
2073
2140
  this.emitLive({ event: 'compaction.complete', agent: this.source, phase: 'queued' });
2074
2141
  return { compacted: true, result };
2075
2142
  }
@@ -2120,7 +2187,9 @@ fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 });
2120
2187
  const server = net.createServer((socket) => {
2121
2188
  anchor.clients.add(socket);
2122
2189
  socket.setEncoding('utf8');
2123
- writeLine(socket, { type: 'anchor_hello', anchor: anchor.snapshot() });
2190
+ // The greeting identifies the anchor. A client that wants the buffered
2191
+ // events asks for a snapshot, as every client already does on connecting.
2192
+ writeLine(socket, { type: 'anchor_hello', anchor: anchor.snapshot({ liveEvents: false }) });
2124
2193
  const lines = readline.createInterface({ input: socket });
2125
2194
  const detach = () => {
2126
2195
  anchor.clients.delete(socket);
@@ -2147,7 +2216,7 @@ const server = net.createServer((socket) => {
2147
2216
  Promise.resolve()
2148
2217
  .then(async () => {
2149
2218
  if (request.type === 'snapshot')
2150
- return anchor.snapshot();
2219
+ return anchor.snapshot({ liveEvents: request.liveEvents !== false });
2151
2220
  if (request.type === 'send_message')
2152
2221
  return anchor.start(request);
2153
2222
  if (request.type === 'interrupt')
@@ -38,7 +38,7 @@ export function harnessExitError(harness, code, signal, stderr) {
38
38
  harnessExit: { harness: String(harness), code: code ?? null, signal: signal ?? null, stderr },
39
39
  });
40
40
  }
41
- function harnessLabel(harness) {
41
+ export function harnessLabel(harness) {
42
42
  const name = String(harness || 'agent');
43
43
  return name.charAt(0).toUpperCase() + name.slice(1);
44
44
  }
@@ -0,0 +1,236 @@
1
+ /** Machine-local harness CLI upgrades, callable by the Turns daemon. */
2
+ import { spawn } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { executablePath } from "./executable-path.js";
7
+ import { harnessLabel } from "./harness-exit.js";
8
+ import { ACP_PROFILES, codexCommandIsAppManaged, HARNESS_IDS, RPC_PROFILES, resolveHarnessCommand, } from "./harness-registry.js";
9
+ const VERSION_TIMEOUT_MS = 15_000;
10
+ const UPDATE_TIMEOUT_MS = 5 * 60_000;
11
+ const OUTPUT_LIMIT = 64 * 1024;
12
+ /**
13
+ * Native installers whose CLI updates itself without prompting. Each entry was
14
+ * checked against the CLI's own `--help`; a harness absent here is upgradable
15
+ * only when a package manager owns its executable. Some updaters ask before
16
+ * installing (Devin) or rewrite a source checkout (Hermes), so they are left
17
+ * out rather than run unattended.
18
+ */
19
+ const SELF_UPDATE_ARGS = {
20
+ claude: ['update'],
21
+ codex: ['update'],
22
+ cursor: ['update'],
23
+ antigravity: ['update'],
24
+ omp: ['update'],
25
+ grok: ['update'],
26
+ goose: ['update'],
27
+ fx: ['upgrade'],
28
+ kimi: ['upgrade'],
29
+ opencode: ['upgrade'],
30
+ kilo: ['upgrade'],
31
+ };
32
+ /** Run the resolved executable directly—never through a shell—so update
33
+ * requests cannot become arbitrary command execution. */
34
+ function runCommand(command, args, timeoutMs) {
35
+ return new Promise((resolve) => {
36
+ const child = spawn(command, args, {
37
+ cwd: os.homedir(),
38
+ // npm and npm-installed CLIs are `#!/usr/bin/env node` scripts; a
39
+ // service manager's PATH may not include the daemon's own Node.
40
+ env: { ...process.env, PATH: executablePath() },
41
+ stdio: ['ignore', 'pipe', 'pipe'],
42
+ });
43
+ let output = '';
44
+ let settled = false;
45
+ const settle = (result) => {
46
+ if (settled)
47
+ return;
48
+ settled = true;
49
+ clearTimeout(timer);
50
+ resolve(result);
51
+ };
52
+ const timer = setTimeout(() => {
53
+ try {
54
+ child.kill('SIGKILL');
55
+ }
56
+ catch { }
57
+ settle({ output, exitCode: null, timedOut: true });
58
+ }, timeoutMs);
59
+ timer.unref?.();
60
+ const collect = (chunk) => {
61
+ if (output.length < OUTPUT_LIMIT)
62
+ output += String(chunk).slice(0, OUTPUT_LIMIT - output.length);
63
+ };
64
+ child.stdout.on('data', collect);
65
+ child.stderr.on('data', collect);
66
+ child.on('error', () => settle({ output, exitCode: null, failed: true }));
67
+ child.on('close', (exitCode) => settle({ output, exitCode }));
68
+ });
69
+ }
70
+ /** The first dotted version in a CLI's `--version` output, whatever its
71
+ * prefix (`codex-cli 0.164.0`, `omp/18.0.4`, `GitHub Copilot CLI 1.0.80.`). */
72
+ export function parseHarnessVersion(output) {
73
+ const text = String(output || '').replace(/\u001b\[[0-9;?]*[A-Za-z]/gu, '');
74
+ const version = text.match(/\d+(?:\.\d+)+(?:-[0-9A-Za-z.]+)?/u)?.[0] || '';
75
+ return version.replace(/\.$/u, '').slice(0, 120);
76
+ }
77
+ function commandFailure(result, label, fallback) {
78
+ if (result.timedOut)
79
+ return new Error(`The ${label} update timed out.`);
80
+ const lines = result.output
81
+ .replace(/\u001b\[[0-9;?]*[A-Za-z]/gu, '')
82
+ .split('\n')
83
+ .map((line) => line.trim())
84
+ // npm ends every failure by pointing at its debug log.
85
+ .filter((line) => line && !/A complete log of this run can be found/iu.test(line));
86
+ return new Error(lines.at(-1) || fallback);
87
+ }
88
+ function realpathOrSelf(file) {
89
+ try {
90
+ return fs.realpathSync(file);
91
+ }
92
+ catch {
93
+ return file;
94
+ }
95
+ }
96
+ function findExecutable(name, preferred) {
97
+ for (const directory of [...preferred, ...executablePath().split(path.delimiter)]) {
98
+ const candidate = path.join(directory, name);
99
+ try {
100
+ fs.accessSync(candidate, fs.constants.X_OK);
101
+ return candidate;
102
+ }
103
+ catch { }
104
+ }
105
+ return '';
106
+ }
107
+ export function harnessUpgradeCommand(harness, command, lookup = {}) {
108
+ const resolved = (lookup.realpath || realpathOrSelf)(command).replaceAll('\\', '/');
109
+ const find = lookup.findExecutable || findExecutable;
110
+ const platform = lookup.platform || process.platform;
111
+ // `npm i -g <pkg>` links <prefix>/bin/<cli> into <prefix>/lib/node_modules.
112
+ // Requiring the `lib/` layout keeps pnpm and Bun global stores, which also
113
+ // contain node_modules, away from an npm that would install a second copy.
114
+ // Windows npm is a .cmd shim, which cannot be spawned without a shell.
115
+ const npm = resolved.match(/^(.+?)\/lib\/node_modules\/((?:@[^/]+\/)?[^/@][^/]*)\//u);
116
+ if (npm && platform !== 'win32') {
117
+ const [, prefix, name] = npm;
118
+ const binary = find('npm', [path.join(prefix, 'bin')]);
119
+ if (binary) {
120
+ return {
121
+ manager: 'npm',
122
+ command: binary,
123
+ args: ['install', '-g', '--prefix', prefix, `${name}@latest`],
124
+ check: {
125
+ args: ['view', name, 'version'],
126
+ failure: `${name} is not published on the npm registry, so this installation must be updated on the machine.`,
127
+ },
128
+ };
129
+ }
130
+ }
131
+ const brew = resolved.match(/^(.+?)\/(Cellar|Caskroom)\/([^/]+)\//u);
132
+ if (brew) {
133
+ const binary = find('brew', [path.join(brew[1], 'bin')]);
134
+ if (binary)
135
+ return {
136
+ manager: 'brew',
137
+ command: binary,
138
+ args: ['upgrade', ...(brew[2] === 'Caskroom' ? ['--cask'] : []), brew[3]],
139
+ };
140
+ }
141
+ const uv = resolved.match(/\/uv\/tools\/([^/]+)\//u);
142
+ if (uv) {
143
+ const binary = find('uv', []);
144
+ if (binary)
145
+ return { manager: 'uv', command: binary, args: ['tool', 'upgrade', uv[1]] };
146
+ }
147
+ const pipx = resolved.match(/\/pipx\/venvs\/([^/]+)\//u);
148
+ if (pipx) {
149
+ const binary = find('pipx', []);
150
+ if (binary)
151
+ return { manager: 'pipx', command: binary, args: ['upgrade', pipx[1]] };
152
+ }
153
+ const self = SELF_UPDATE_ARGS[harness];
154
+ return self ? { manager: 'self', command, args: self } : null;
155
+ }
156
+ /** The executable the agent anchor launches for a harness. */
157
+ function harnessExecutable(harness) {
158
+ return resolveHarnessCommand(harness, ACP_PROFILES[harness] || RPC_PROFILES[harness] || {});
159
+ }
160
+ export function createHarnessUpdateManager(dependencies = {}) {
161
+ const harnesses = dependencies.harnesses || HARNESS_IDS;
162
+ const resolveCommand = dependencies.resolveCommand || harnessExecutable;
163
+ const isAppManaged = dependencies.isAppManaged ||
164
+ ((harness, command) => harness === 'codex' && codexCommandIsAppManaged(command));
165
+ const upgradeCommand = dependencies.upgradeCommand || ((harness, command) => harnessUpgradeCommand(harness, command));
166
+ const run = dependencies.run || runCommand;
167
+ const active = new Map();
168
+ const plan = (harness) => {
169
+ const label = harnessLabel(harness);
170
+ if (!harnesses.includes(harness))
171
+ throw new Error(`Turns cannot update ${label}.`);
172
+ const command = resolveCommand(harness);
173
+ if (isAppManaged(harness, command)) {
174
+ throw new Error(`${label} is managed by the macOS app. Update the ${label} app to update its bundled CLI.`);
175
+ }
176
+ const upgrade = upgradeCommand(harness, command);
177
+ if (!upgrade)
178
+ throw new Error(`Turns does not know how to update this ${label} installation. Update it on the machine.`);
179
+ return { label, command, upgrade };
180
+ };
181
+ const readVersion = async (command, label, fallback) => {
182
+ const result = await run(command, ['--version'], VERSION_TIMEOUT_MS);
183
+ if (result.failed || result.timedOut || result.exitCode !== 0)
184
+ throw commandFailure(result, label, fallback);
185
+ return parseHarnessVersion(result.output);
186
+ };
187
+ const perform = async (harness) => {
188
+ const { label, command, upgrade } = plan(harness);
189
+ const previousVersion = await readVersion(command, label, `${label} is not installed or its version could not be read.`);
190
+ if (upgrade.check) {
191
+ const checked = await run(upgrade.command, upgrade.check.args, VERSION_TIMEOUT_MS);
192
+ if (checked.timedOut)
193
+ throw new Error(`The ${label} update timed out.`);
194
+ if (checked.failed || checked.exitCode !== 0)
195
+ throw new Error(upgrade.check.failure);
196
+ }
197
+ const updateRun = await run(upgrade.command, upgrade.args, UPDATE_TIMEOUT_MS);
198
+ if (updateRun.failed || updateRun.timedOut || updateRun.exitCode !== 0) {
199
+ const manual = [path.basename(upgrade.command), ...upgrade.args].join(' ');
200
+ throw commandFailure(updateRun, label, upgrade.manager === 'self'
201
+ ? `${label} could not update itself. Run \`${manual}\` in a terminal on the machine.`
202
+ : `${upgrade.manager} could not upgrade ${label}. Run \`${manual}\` in a terminal on the machine.`);
203
+ }
204
+ // Resolve again because the updater may replace a shim or install path.
205
+ const currentVersion = await readVersion(resolveCommand(harness), label, `${label} updated, but its new version could not be read.`);
206
+ return {
207
+ ...(previousVersion ? { previousVersion } : {}),
208
+ ...(currentVersion ? { currentVersion } : {}),
209
+ updated: Boolean(previousVersion && currentVersion && previousVersion !== currentVersion),
210
+ };
211
+ };
212
+ return {
213
+ /** Harnesses installed here in a way this daemon can upgrade. */
214
+ available() {
215
+ return harnesses.filter((harness) => {
216
+ try {
217
+ plan(harness);
218
+ return true;
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ });
224
+ },
225
+ /** Concurrent clicks for one harness share one installer process instead
226
+ * of racing writes to the same installation. */
227
+ update(harness) {
228
+ let pending = active.get(harness);
229
+ if (!pending) {
230
+ pending = perform(harness).finally(() => active.delete(harness));
231
+ active.set(harness, pending);
232
+ }
233
+ return pending;
234
+ },
235
+ };
236
+ }
@@ -1490,7 +1490,6 @@ function nativeProcessSnapshots(procRoot) {
1490
1490
  continue;
1491
1491
  let argv = [];
1492
1492
  let environ = '';
1493
- let fdTargets = [];
1494
1493
  try {
1495
1494
  argv = fs
1496
1495
  .readFileSync(path.join(procRoot, entry.name, 'cmdline'), 'utf8')
@@ -1502,22 +1501,39 @@ function nativeProcessSnapshots(procRoot) {
1502
1501
  environ = fs.readFileSync(path.join(procRoot, entry.name, 'environ'), 'utf8');
1503
1502
  }
1504
1503
  catch { }
1505
- try {
1506
- fdTargets = fs.readdirSync(path.join(procRoot, entry.name, 'fd')).flatMap((descriptor) => {
1504
+ if (!argv.length && !environ)
1505
+ continue;
1506
+ // Open files are wanted only for the few processes that turn out to be a
1507
+ // harness, and listing them is the expensive part of this scan (a readlink
1508
+ // per descriptor of every process on the machine), so they are read on
1509
+ // first use.
1510
+ let fdTargets = null;
1511
+ processes.push({
1512
+ pid: entry.name,
1513
+ argv,
1514
+ cmdline: argv.join(' '),
1515
+ environ,
1516
+ get fdTargets() {
1517
+ if (fdTargets)
1518
+ return fdTargets;
1519
+ fdTargets = [];
1507
1520
  try {
1508
- const target = fs
1509
- .readlinkSync(path.join(procRoot, entry.name, 'fd', descriptor))
1510
- .replace(/ \(deleted\)$/u, '');
1511
- return path.isAbsolute(target) ? [target] : [];
1521
+ fdTargets = fs.readdirSync(path.join(procRoot, entry.name, 'fd')).flatMap((descriptor) => {
1522
+ try {
1523
+ const target = fs
1524
+ .readlinkSync(path.join(procRoot, entry.name, 'fd', descriptor))
1525
+ .replace(/ \(deleted\)$/u, '');
1526
+ return path.isAbsolute(target) ? [target] : [];
1527
+ }
1528
+ catch {
1529
+ return [];
1530
+ }
1531
+ });
1512
1532
  }
1513
- catch {
1514
- return [];
1515
- }
1516
- });
1517
- }
1518
- catch { }
1519
- if (argv.length || environ)
1520
- processes.push({ pid: entry.name, argv, cmdline: argv.join(' '), environ, fdTargets });
1533
+ catch { }
1534
+ return fdTargets;
1535
+ },
1536
+ });
1521
1537
  }
1522
1538
  return processes;
1523
1539
  }
@@ -1625,6 +1641,22 @@ function registeredClaudeSessionIds(processes, procRoot, sessionsRoot) {
1625
1641
  }
1626
1642
  return sessionIds;
1627
1643
  }
1644
+ /**
1645
+ * A descriptor's transcript path with symlinks resolved, remembered with the
1646
+ * descriptor. This scan runs every ten seconds on the daemon's main thread over
1647
+ * every known session, and resolving thousands of paths each time (twice) was
1648
+ * most of a stall that delayed live rows. Descriptors are replaced wholesale by
1649
+ * each discovery, so a path that is re-pointed is seen at the next one.
1650
+ */
1651
+ const canonicalDescriptorFiles = new WeakMap();
1652
+ function descriptorCanonicalFile(descriptor) {
1653
+ const held = canonicalDescriptorFiles.get(descriptor);
1654
+ if (held !== undefined)
1655
+ return held;
1656
+ const file = canonicalProcessFile(descriptor.file);
1657
+ canonicalDescriptorFiles.set(descriptor, file);
1658
+ return file;
1659
+ }
1628
1660
  function queueManagedKeysFromProcesses(descriptors, processes) {
1629
1661
  const transcriptFiles = new Set();
1630
1662
  for (const process of processes) {
@@ -1644,17 +1676,16 @@ function queueManagedKeysFromProcesses(descriptors, processes) {
1644
1676
  transcriptFiles.add(path.resolve(environment.TURNS_TRANSCRIPT_PATH));
1645
1677
  }
1646
1678
  }
1679
+ // No queue-managed harness is running: nothing to match, and no reason to
1680
+ // resolve every known transcript's path to find that out.
1681
+ if (!transcriptFiles.size)
1682
+ return new Set();
1647
1683
  return new Set(descriptors.flatMap((descriptor) => {
1648
1684
  if (!descriptor?.file)
1649
1685
  return [];
1650
- let file;
1651
- try {
1652
- file = fs.realpathSync(descriptor.file);
1653
- }
1654
- catch {
1655
- file = path.resolve(descriptor.file);
1656
- }
1657
- return transcriptFiles.has(file) ? [`${descriptor.harness}\0${descriptor.sourcePath}`] : [];
1686
+ return transcriptFiles.has(descriptorCanonicalFile(descriptor))
1687
+ ? [`${descriptor.harness}\0${descriptor.sourcePath}`]
1688
+ : [];
1658
1689
  }));
1659
1690
  }
1660
1691
  export function queueManagedNativeDescriptorKeys(descriptors, procRoot = '/proc') {
@@ -1670,14 +1701,13 @@ export function externallyManagedNativeDescriptorKeys(descriptors, procRoot = '/
1670
1701
  if (queueManaged.has(key))
1671
1702
  continue;
1672
1703
  const session = descriptorNativeSessionId(descriptor);
1673
- const file = descriptor?.file ? canonicalProcessFile(descriptor.file) : '';
1674
1704
  // A process with no session key is evidence only that a harness is
1675
1705
  // running, not that it owns every historical transcript for that harness.
1676
1706
  // An exact open transcript, however, identifies that one descriptor.
1677
- if (!session && !file)
1707
+ if (!session && !descriptor?.file)
1678
1708
  continue;
1679
1709
  const group = byHarness.get(descriptor.harness) || [];
1680
- group.push({ key, session, file });
1710
+ group.push({ key, session, descriptor });
1681
1711
  byHarness.set(descriptor.harness, group);
1682
1712
  }
1683
1713
  const managed = new Set();
@@ -1697,7 +1727,9 @@ export function externallyManagedNativeDescriptorKeys(descriptors, procRoot = '/
1697
1727
  const openFiles = new Set(process.fdTargets.map(canonicalProcessFile));
1698
1728
  for (const candidate of candidates) {
1699
1729
  if ((candidate.session && process.cmdline.includes(candidate.session)) ||
1700
- (candidate.file && openFiles.has(candidate.file))) {
1730
+ (openFiles.size > 0 &&
1731
+ candidate.descriptor?.file &&
1732
+ openFiles.has(descriptorCanonicalFile(candidate.descriptor)))) {
1701
1733
  managed.add(candidate.key);
1702
1734
  }
1703
1735
  }
@@ -87,7 +87,11 @@ export function pathCandidates(row) {
87
87
  continue;
88
88
  if (found.includes(candidate))
89
89
  continue;
90
- found.push(candidate);
90
+ // A regex match is a slice of the row it came from, and a slice keeps the
91
+ // whole row alive for as long as it is remembered. The resolver remembers
92
+ // candidates per session for the life of the daemon, so each one has to
93
+ // be its own small string rather than a window onto kilobytes of transcript.
94
+ found.push(Buffer.from(candidate).toString());
91
95
  if (found.length >= MAX_PATH_CANDIDATES_PER_ROW)
92
96
  break;
93
97
  }
@@ -6,8 +6,8 @@ import { Readable } from 'node:stream';
6
6
  import { pipeline } from 'node:stream/promises';
7
7
  import { logError } from "./logging.js";
8
8
  import { QueueDispatchLifecycle, QUEUE_RESTART_MESSAGE } from "./queue-lifecycle.js";
9
- import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentTurn } from "./agent-anchor-client.js";
10
- import { createCodexUpdateManager } from "./codex-update.js";
9
+ import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, STATUS_SNAPSHOT, waitForAgentTurn, } from "./agent-anchor-client.js";
10
+ import { createHarnessUpdateManager } from "./harness-update.js";
11
11
  import { isAsyncQuestionId } from "./codex-async-question-recovery.js";
12
12
  import { assertAllowedWorkingDirectory, workingDirectoryIsAllowed } from "./directory-policy.js";
13
13
  import { browseMachinePath, streamMachineFileRange } from "./file-browsing.js";
@@ -31,7 +31,7 @@ const DEFAULT_REDACTOR = createSecretRedactor();
31
31
  export async function answerAnchorInteraction(entry, interactionId, response) {
32
32
  if (!entry || entry.connection.closed)
33
33
  throw new Error('The detached harness is offline.');
34
- entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
34
+ entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => entry.snapshot);
35
35
  const pending = entry.snapshot?.pendingInteractions?.some((interaction) => interaction.id === interactionId);
36
36
  // Async questions can be reconstructed from the native transcript without
37
37
  // appearing in the app-server's live/resumed item projection. Let the anchor
@@ -341,6 +341,14 @@ export function runtimeEvent(anchorName, messageId, event, redactor = DEFAULT_RE
341
341
  }
342
342
  return null;
343
343
  }
344
+ /** Registration capabilities for harness upgrades. `codexUpdate` remains for
345
+ * backends that only know how to route Codex upgrades. */
346
+ export function updateCapabilities(harnesses) {
347
+ return {
348
+ ...(harnesses.length ? { harnessUpdates: harnesses } : {}),
349
+ ...(harnesses.includes('codex') ? { codexUpdate: true } : {}),
350
+ };
351
+ }
344
352
  export function resultError(result) {
345
353
  if (!result)
346
354
  return new Error('The anchored agent returned no result.');
@@ -371,7 +379,7 @@ export function queueFailureIsRetryable(error, promptStarted) {
371
379
  export async function actualHarnessStatus(entry) {
372
380
  if (!entry || entry.connection.closed)
373
381
  return 'stopped';
374
- const snapshot = await entry.connection.request('snapshot', {}, 5_000);
382
+ const snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000);
375
383
  entry.snapshot = snapshot;
376
384
  return snapshot.status === 'running' ? 'running' : 'idle';
377
385
  }
@@ -586,7 +594,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
586
594
  const usableAnchor = async (sessionId, source, agentSessionId, modelSettings = false) => {
587
595
  let entry = anchors.get(sessionId);
588
596
  if (entry && !entry.connection.closed) {
589
- entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
597
+ entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => entry.snapshot);
590
598
  if (String(entry.snapshot.source || '') === source &&
591
599
  String(entry.snapshot.agentSessionId || '') === agentSessionId &&
592
600
  (!modelSettings || entry.snapshot.capabilities?.includes('model_settings')))
@@ -835,7 +843,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
835
843
  idleTimeoutMs: QUEUE_WARM_MS,
836
844
  }, 30_000);
837
845
  });
838
- entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
846
+ entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => entry.snapshot);
839
847
  // Registration after acceptance is safe: the connection retains recent
840
848
  // events and the snapshot carries a turn that completed immediately.
841
849
  result = await waitForAgentTurn(entry.connection, claim.prompt.id, QUEUE_TURN_TIMEOUT_MS, entry.snapshot, eventCursor);
@@ -844,7 +852,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
844
852
  await request({ type: 'session.native.bind', sessionId, agentSessionId: createdIdentity });
845
853
  }
846
854
  }
847
- entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => ({
855
+ entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => ({
848
856
  ...entry.snapshot,
849
857
  status: 'idle',
850
858
  }));
@@ -880,7 +888,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
880
888
  stopClaimRenewal(held);
881
889
  if (held?.entry && !held.entry.connection.closed) {
882
890
  held.entry.snapshot = await held.entry.connection
883
- .request('snapshot', {}, 2_000)
891
+ .request('snapshot', STATUS_SNAPSHOT, 2_000)
884
892
  .catch(() => held.entry.snapshot);
885
893
  await reportHarness(sessionId, held.entry.snapshot?.status === 'running' ? 'running' : 'idle', held.entry).catch(() => { });
886
894
  }
@@ -907,8 +915,15 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
907
915
  };
908
916
  const recoverAnchors = async () => {
909
917
  for (const [sessionId, entry] of anchors) {
910
- const snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
911
- entry.snapshot = snapshot;
918
+ // Recovery is the one reader of an anchor's buffered events, so it is the
919
+ // one request that asks for them; they are not kept past this pass.
920
+ // A full buffer can be megabytes; give a busy anchor a second, longer
921
+ // chance before recovering the turn without its buffered events.
922
+ const snapshot = await entry.connection
923
+ .request('snapshot', {}, 5_000)
924
+ .catch(() => entry.connection.request('snapshot', {}, 20_000))
925
+ .catch(() => entry.snapshot);
926
+ entry.snapshot = { ...snapshot, liveEvents: [] };
912
927
  await reportHarness(sessionId, snapshot.status === 'running' ? 'running' : 'idle', entry).catch(() => { });
913
928
  const turn = snapshot.currentTurn;
914
929
  // A warm anchor retains its terminal turn for diagnostics. Probe the
@@ -1039,7 +1054,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1039
1054
  try {
1040
1055
  if (!entry || entry.connection.closed)
1041
1056
  throw new Error('The detached harness is offline.');
1042
- entry.snapshot = await entry.connection.request('snapshot', {}, 5_000);
1057
+ entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000);
1043
1058
  if (entry.snapshot?.status === 'running')
1044
1059
  throw new Error('The detached harness is actively running.');
1045
1060
  if (action === 'compact' && entry.snapshot?.pendingInteractions?.length) {
@@ -1048,7 +1063,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1048
1063
  await entry.connection.request(action, {}, action === 'compact' ? QUEUE_TURN_TIMEOUT_MS : 5_000);
1049
1064
  if (action === 'compact') {
1050
1065
  await flushRuntime(sessionId);
1051
- entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
1066
+ entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => entry.snapshot);
1052
1067
  await reportHarness(sessionId, 'idle', entry);
1053
1068
  }
1054
1069
  else {
@@ -1339,18 +1354,22 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1339
1354
  });
1340
1355
  }
1341
1356
  };
1342
- /** Upgrade the machine's Codex installation. Only this fixed, resolved
1343
- * command is exposed; the browser cannot supply a binary, arguments, or
1344
- * shell input. Errors are redacted before crossing the machine boundary. */
1345
- const codexUpdate = createCodexUpdateManager();
1346
- const handleCodexUpdate = async (message, replyRpc) => {
1357
+ /** Upgrade one of the machine's harness CLIs. Only a fixed command chosen
1358
+ * from the resolved executable's install location is run; the browser names
1359
+ * a harness but cannot supply a binary, arguments, or shell input. Errors
1360
+ * are redacted before crossing the machine boundary. The message keeps its
1361
+ * Codex-era name; a request without a harness is from a Codex-only backend. */
1362
+ const harnessUpdate = createHarnessUpdateManager();
1363
+ const handleHarnessUpdate = async (message, replyRpc) => {
1347
1364
  const sessionId = String(message.sessionId || '');
1365
+ const harness = String(message.harness || 'codex');
1348
1366
  try {
1349
- const answer = await codexUpdate.update();
1367
+ const answer = await harnessUpdate.update(harness);
1350
1368
  replyRpc.notify({
1351
1369
  type: 'session.codex.update.result',
1352
1370
  requestId: message.requestId,
1353
1371
  sessionId,
1372
+ harness,
1354
1373
  ok: true,
1355
1374
  answer,
1356
1375
  });
@@ -1360,6 +1379,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1360
1379
  type: 'session.codex.update.result',
1361
1380
  requestId: message.requestId,
1362
1381
  sessionId,
1382
+ harness,
1363
1383
  ok: false,
1364
1384
  error: redactor.redactText(error?.message || String(error)),
1365
1385
  });
@@ -1424,33 +1444,44 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1424
1444
  }
1425
1445
  }, HARNESS_HEARTBEAT_MS);
1426
1446
  heartbeat.unref?.();
1447
+ // A browser request is answered over the same socket it arrived on. When
1448
+ // that socket drops mid-request, the handler's reply throws, and so does the
1449
+ // error reply in its catch block. Nothing awaits these handlers, so that
1450
+ // second throw used to surface as an unhandled rejection and take the whole
1451
+ // daemon down with every running session's watcher. The browser has already
1452
+ // been told its connection closed; there is nobody left to answer.
1453
+ const answer = (handler, message) => {
1454
+ void Promise.resolve()
1455
+ .then(() => handler(message, rpc))
1456
+ .catch((error) => logError(`Could not answer ${message.type}: ${error?.message || error}\n`));
1457
+ };
1427
1458
  rpc.onEvent((message) => {
1428
1459
  if (lifecycle.stopping)
1429
1460
  return;
1430
1461
  if (message.type === 'queue.ready')
1431
1462
  void tryClaim(message.sessionId);
1432
1463
  if (message.type === 'session.steer')
1433
- void handleSteer(message, rpc);
1464
+ answer(handleSteer, message);
1434
1465
  if (message.type === 'session.interrupt.request')
1435
- void handleInterrupt(message, rpc);
1466
+ answer(handleInterrupt, message);
1436
1467
  if (message.type === 'session.anchor.action.request')
1437
- void handleAnchorAction(message, rpc);
1468
+ answer(handleAnchorAction, message);
1438
1469
  if (message.type === 'session.interaction.answer.request')
1439
- void handleInteractionAnswer(message, rpc);
1470
+ answer(handleInteractionAnswer, message);
1440
1471
  if (message.type === 'session.file.browse.request')
1441
- void handleFileBrowse(message, rpc);
1472
+ answer(handleFileBrowse, message);
1442
1473
  if (message.type === 'session.file.range.request')
1443
- void handleFileRange(message, rpc);
1474
+ answer(handleFileRange, message);
1444
1475
  if (message.type === 'session.file.search.request')
1445
- void handleFileSearch(message, rpc);
1476
+ answer(handleFileSearch, message);
1446
1477
  if (message.type === 'session.git.read.request')
1447
- void handleGitRead(message, rpc);
1478
+ answer(handleGitRead, message);
1448
1479
  if (message.type === 'session.harness.auth.request')
1449
- void handleHarnessAuth(message, rpc);
1480
+ answer(handleHarnessAuth, message);
1450
1481
  if (message.type === 'session.codex.update.request')
1451
- void handleCodexUpdate(message, rpc);
1482
+ answer(handleHarnessUpdate, message);
1452
1483
  if (message.type === 'session.file.edit.request')
1453
- void handleFileEdit(message, rpc);
1484
+ answer(handleFileEdit, message);
1454
1485
  });
1455
1486
  const registered = new WeakSet();
1456
1487
  const registering = new WeakSet();
@@ -1469,7 +1500,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1469
1500
  git: true,
1470
1501
  fileRanges: true,
1471
1502
  harnessAuth: true,
1472
- ...(codexUpdate.available() ? { codexUpdate: true } : {}),
1503
+ ...updateCapabilities(harnessUpdate.available()),
1473
1504
  },
1474
1505
  workerId,
1475
1506
  workerInstanceId,
@@ -1490,7 +1521,15 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1490
1521
  rpc.onConnect((control) => {
1491
1522
  void registerControl(control);
1492
1523
  });
1493
- await registerControl(await rpc.current());
1524
+ // Starting offline must not end this function: the loop below is the only
1525
+ // thing that sends heartbeats, probes for a half-open socket and re-polls
1526
+ // for missed queue events, and it already tolerates a failed connection.
1527
+ try {
1528
+ await registerControl(await rpc.current());
1529
+ }
1530
+ catch (error) {
1531
+ logError(`Queue worker could not connect yet: ${error?.message || error}\n`);
1532
+ }
1494
1533
  while (!lifecycle.stopping) {
1495
1534
  await backgroundWait(MACHINE_HEARTBEAT_MS);
1496
1535
  if (lifecycle.stopping)
@@ -404,6 +404,37 @@ export function linkNativeSessionParents(machine, descriptors) {
404
404
  ];
405
405
  });
406
406
  }
407
+ /**
408
+ * The row the server has reached, when a rejected ingest batch was in fact
409
+ * already stored in full; null when the rejection is a real one.
410
+ */
411
+ export function ingestOffsetAcknowledges(error, startRow, rowCount) {
412
+ const offset = /^ROW_OFFSET:(\d+)$/u.exec(String(error?.code || ''));
413
+ if (!offset)
414
+ return null;
415
+ const reached = Number(offset[1]);
416
+ return reached >= startRow + rowCount ? reached : null;
417
+ }
418
+ /**
419
+ * Bring a descriptor up to the file as it is now.
420
+ *
421
+ * The inventory scan stats each transcript in a worker and the result is
422
+ * recorded here some time later. An agent that wrote in between leaves the
423
+ * descriptor describing a shorter file than the one on disk. Recording the
424
+ * fresh stat as "already seen" while uploading only to the descriptor's stale
425
+ * size strands those rows: nothing looks changed until the next append or the
426
+ * five-minute audit. A database projection's file is its whole store, not the
427
+ * session, so its size says nothing about the session and is left alone.
428
+ */
429
+ export function adoptGrowthSinceInventory(descriptor, stat) {
430
+ if (String(descriptor.sourcePath || '').includes('.db#'))
431
+ return false;
432
+ if (Number(stat.size) === Number(descriptor.size))
433
+ return false;
434
+ descriptor.size = stat.size;
435
+ descriptor.mtimeMs = stat.mtimeMs;
436
+ return true;
437
+ }
407
438
  export function nativeDescriptorFingerprint(descriptor) {
408
439
  return JSON.stringify([
409
440
  Number(descriptor.size) || 0,
@@ -450,32 +481,39 @@ function nativeTimestamp(raw, descriptor, rowIndex) {
450
481
  const base = Date.parse(descriptor.createdAt) || descriptor.mtimeMs || Date.now();
451
482
  return new Date(base + rowIndex).toISOString();
452
483
  }
453
- async function* completeNativeFileLines(file, startOffset, endOffset) {
484
+ export async function* completeNativeFileLines(file, startOffset, endOffset) {
454
485
  if (endOffset < startOffset)
455
486
  return;
456
- let carry = Buffer.alloc(0);
457
- let carryOffset = startOffset;
487
+ // The unfinished line so far, as the chunks it arrived in. Joining them on
488
+ // every read and searching the whole thing again made one very long row
489
+ // (inline images, a giant tool output) quadratic: 80 MB took half a minute.
490
+ let carried = [];
491
+ let carriedBytes = 0;
492
+ let consumed = startOffset;
458
493
  const input = fs.createReadStream(file, { start: startOffset, end: endOffset });
459
494
  try {
460
495
  for await (const chunk of input) {
461
- const buffer = carry.length ? Buffer.concat([carry, chunk]) : chunk;
462
- const bufferOffset = carryOffset;
463
496
  let lineStart = 0;
464
497
  for (;;) {
465
- const newline = buffer.indexOf(0x0a, lineStart);
498
+ // Only the new chunk can contain the newline that ends the carry.
499
+ const newline = chunk.indexOf(0x0a, lineStart);
466
500
  if (newline < 0)
467
501
  break;
468
- let line = buffer.subarray(lineStart, newline);
502
+ const tail = chunk.subarray(lineStart, newline);
503
+ let line = carried.length ? Buffer.concat([...carried, tail], carriedBytes + tail.length) : tail;
504
+ consumed += line.length + 1;
505
+ carried = [];
506
+ carriedBytes = 0;
469
507
  if (line.at(-1) === 0x0d)
470
508
  line = line.subarray(0, -1);
471
- yield {
472
- raw: line.toString('utf8'),
473
- endOffset: bufferOffset + newline + 1,
474
- };
509
+ yield { raw: line.toString('utf8'), endOffset: consumed };
475
510
  lineStart = newline + 1;
476
511
  }
477
- carry = buffer.subarray(lineStart);
478
- carryOffset = bufferOffset + lineStart;
512
+ if (lineStart < chunk.length) {
513
+ const rest = chunk.subarray(lineStart);
514
+ carried.push(rest);
515
+ carriedBytes += rest.length;
516
+ }
479
517
  }
480
518
  }
481
519
  finally {
@@ -638,6 +676,10 @@ export class RpcSocket {
638
676
  this.socket.on('message', (data) => this.onMessage(String(data), (ready) => settle(resolve, ready), (error) => settle(reject, error)));
639
677
  this.socket.on('close', (code, reason) => {
640
678
  const error = socketError(`WebSocket closed (${code}): ${String(reason)}`);
679
+ // A server that accepts the upgrade and closes before saying "ready"
680
+ // (a deploy, a restarting object) has answered. Without this the
681
+ // connect waited out the whole ready deadline before it could retry.
682
+ settle(reject, error);
641
683
  for (const request of this.pending.values()) {
642
684
  clearTimeout(request.timer);
643
685
  request.reject(error);
@@ -1087,13 +1129,27 @@ export async function uploadNativeSession(rpc, machine, descriptor, cursor = {},
1087
1129
  if (!held.length)
1088
1130
  return;
1089
1131
  const rows = held.map((entry) => entry.event);
1090
- const acknowledgement = await rpc.request({
1091
- type: 'ingest',
1092
- sessionId: uploadSessionId,
1093
- startRow: rows[0].index,
1094
- rows,
1095
- ...(initialUpload ? { deferReduction: true } : {}),
1096
- });
1132
+ let acknowledgement;
1133
+ try {
1134
+ acknowledgement = await rpc.request({
1135
+ type: 'ingest',
1136
+ sessionId: uploadSessionId,
1137
+ startRow: rows[0].index,
1138
+ rows,
1139
+ ...(initialUpload ? { deferReduction: true } : {}),
1140
+ });
1141
+ }
1142
+ catch (error) {
1143
+ // The connection resends a request whose socket died. When only the
1144
+ // acknowledgement was lost, the server already holds these rows and
1145
+ // answers the resend with the offset it has reached. That is an
1146
+ // acknowledgement: treating it as a failure put the session into backoff
1147
+ // and then re-read its whole transcript from the first byte.
1148
+ const acknowledged = ingestOffsetAcknowledges(error, rows[0].index, rows.length);
1149
+ if (acknowledged === null)
1150
+ throw error;
1151
+ acknowledgement = { nextRow: acknowledged };
1152
+ }
1097
1153
  nextRow = Number(acknowledgement.nextRow);
1098
1154
  sent += rows.length;
1099
1155
  batchesSent += 1;
@@ -1624,6 +1680,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1624
1680
  try {
1625
1681
  const stat = fs.statSync(descriptor.file);
1626
1682
  physicalStats.set(sessionId, { size: stat.size, mtimeMs: stat.mtimeMs });
1683
+ adoptGrowthSinceInventory(descriptor, stat);
1627
1684
  }
1628
1685
  catch { }
1629
1686
  const saved = state.get(sessionId);
@@ -2018,6 +2075,10 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
2018
2075
  String(descriptor.parentSessionId || ''),
2019
2076
  NATIVE_DESCRIPTOR_VERSION,
2020
2077
  String(previousFingerprint?.[5] || nativeDescriptorMetadataFingerprint(descriptor)),
2078
+ // The same optional slot nativeDescriptorFingerprint appends. Without
2079
+ // it a continued session's fingerprint lost the slot on every append,
2080
+ // and each discovery reopened the session to put it back.
2081
+ ...(descriptor.supersedesSessionId ? [String(descriptor.supersedesSessionId)] : []),
2021
2082
  ]);
2022
2083
  }
2023
2084
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,162 +0,0 @@
1
- /** Machine-local Codex CLI self-update, callable by the Turns daemon. */
2
- import { spawn } from 'node:child_process';
3
- import fs from 'node:fs';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
- import { executablePath } from "./executable-path.js";
7
- import { codexCommandIsAppManaged, resolveHarnessCommand } from "./harness-registry.js";
8
- const VERSION_TIMEOUT_MS = 15_000;
9
- const UPDATE_TIMEOUT_MS = 5 * 60_000;
10
- const OUTPUT_LIMIT = 64 * 1024;
11
- /** Run the resolved executable directly—never through a shell—so update
12
- * requests cannot become arbitrary command execution. */
13
- function runCommand(command, args, timeoutMs) {
14
- return new Promise((resolve) => {
15
- const child = spawn(command, args, {
16
- cwd: os.homedir(),
17
- // npm and Codex's npm launcher are `#!/usr/bin/env node` scripts; a
18
- // service manager's PATH may not include the daemon's own Node.
19
- env: { ...process.env, PATH: executablePath() },
20
- stdio: ['ignore', 'pipe', 'pipe'],
21
- });
22
- let output = '';
23
- let settled = false;
24
- const settle = (result) => {
25
- if (settled)
26
- return;
27
- settled = true;
28
- clearTimeout(timer);
29
- resolve(result);
30
- };
31
- const timer = setTimeout(() => {
32
- try {
33
- child.kill('SIGKILL');
34
- }
35
- catch { }
36
- settle({ output, exitCode: null, timedOut: true });
37
- }, timeoutMs);
38
- timer.unref?.();
39
- const collect = (chunk) => {
40
- if (output.length < OUTPUT_LIMIT)
41
- output += String(chunk).slice(0, OUTPUT_LIMIT - output.length);
42
- };
43
- child.stdout.on('data', collect);
44
- child.stderr.on('data', collect);
45
- child.on('error', () => settle({ output, exitCode: null, failed: true }));
46
- child.on('close', (exitCode) => settle({ output, exitCode }));
47
- });
48
- }
49
- export function parseCodexVersion(output) {
50
- const text = String(output || '').trim();
51
- const prefixed = text.match(/codex(?:-cli)?\s+([^\s]+)/iu);
52
- return (prefixed?.[1] || text.split(/\s+/u).at(-1) || '').slice(0, 120);
53
- }
54
- function commandFailure(result, fallback) {
55
- if (result.timedOut)
56
- return new Error('The Codex update timed out.');
57
- const lines = result.output
58
- .replace(/\u001b\[[0-9;?]*[A-Za-z]/gu, '')
59
- .split('\n')
60
- .map((line) => line.trim())
61
- .filter(Boolean);
62
- return new Error(lines.at(-1) || fallback);
63
- }
64
- function realpathOrSelf(file) {
65
- try {
66
- return fs.realpathSync(file);
67
- }
68
- catch {
69
- return file;
70
- }
71
- }
72
- function findExecutable(name, preferred) {
73
- for (const directory of [...preferred, ...executablePath().split(path.delimiter)]) {
74
- const candidate = path.join(directory, name);
75
- try {
76
- fs.accessSync(candidate, fs.constants.X_OK);
77
- return candidate;
78
- }
79
- catch { }
80
- }
81
- return '';
82
- }
83
- export function codexUpgradeCommand(command, lookup = {}) {
84
- const resolved = (lookup.realpath || realpathOrSelf)(command).replaceAll('\\', '/');
85
- const find = lookup.findExecutable || findExecutable;
86
- // `npm i -g @openai/codex` links <prefix>/bin/codex to the package's launcher.
87
- const npm = resolved.match(/^(.*?)\/(?:lib\/)?node_modules\/@openai\/codex\//u);
88
- // Windows npm is a .cmd shim, which cannot be spawned without a shell.
89
- if (npm && process.platform !== 'win32') {
90
- const prefix = npm[1];
91
- const binary = find('npm', [path.join(prefix, 'bin')]);
92
- if (binary)
93
- return { manager: 'npm', command: binary, args: ['install', '-g', '--prefix', prefix, '@openai/codex@latest'] };
94
- }
95
- const brew = resolved.match(/^(.*?)\/(Cellar|Caskroom)\/codex\//u);
96
- if (brew) {
97
- const binary = find('brew', [path.join(brew[1], 'bin')]);
98
- if (binary)
99
- return {
100
- manager: 'brew',
101
- command: binary,
102
- args: ['upgrade', ...(brew[2] === 'Caskroom' ? ['--cask'] : []), 'codex'],
103
- };
104
- }
105
- return { manager: 'codex', command, args: ['update'] };
106
- }
107
- export function createCodexUpdateManager(dependencies = {}) {
108
- const resolveCommand = dependencies.resolveCommand || (() => resolveHarnessCommand('codex'));
109
- const isAppManaged = dependencies.isAppManaged || codexCommandIsAppManaged;
110
- const run = dependencies.run || runCommand;
111
- const upgradeCommand = dependencies.upgradeCommand || ((command) => codexUpgradeCommand(command));
112
- let active = null;
113
- const updateCommand = () => {
114
- const command = resolveCommand();
115
- if (isAppManaged(command)) {
116
- throw new Error('Codex is managed by the macOS app. Update the Codex app to update its bundled CLI.');
117
- }
118
- return command;
119
- };
120
- const perform = async () => {
121
- const beforeRun = await run(updateCommand(), ['--version'], VERSION_TIMEOUT_MS);
122
- if (beforeRun.failed || beforeRun.timedOut || beforeRun.exitCode !== 0) {
123
- throw commandFailure(beforeRun, 'Codex is not installed or its version could not be read.');
124
- }
125
- const previousVersion = parseCodexVersion(beforeRun.output);
126
- const upgrade = upgradeCommand(updateCommand());
127
- const updateRun = await run(upgrade.command, upgrade.args, UPDATE_TIMEOUT_MS);
128
- if (updateRun.failed || updateRun.timedOut || updateRun.exitCode !== 0) {
129
- throw commandFailure(updateRun, upgrade.manager === 'codex'
130
- ? 'Codex could not update itself. Run `codex update` in a terminal on the machine.'
131
- : `${upgrade.manager} could not upgrade Codex. Run \`${[path.basename(upgrade.command), ...upgrade.args].join(' ')}\` in a terminal on the machine.`);
132
- }
133
- // Resolve again because the updater may replace a shim or install path.
134
- const afterRun = await run(updateCommand(), ['--version'], VERSION_TIMEOUT_MS);
135
- if (afterRun.failed || afterRun.timedOut || afterRun.exitCode !== 0) {
136
- throw commandFailure(afterRun, 'Codex updated, but its new version could not be read.');
137
- }
138
- const currentVersion = parseCodexVersion(afterRun.output);
139
- return {
140
- ...(previousVersion ? { previousVersion } : {}),
141
- ...(currentVersion ? { currentVersion } : {}),
142
- updated: Boolean(previousVersion && currentVersion && previousVersion !== currentVersion),
143
- };
144
- };
145
- return {
146
- /** App-bundled Codex is updated with the app, not by its CLI command. */
147
- available() {
148
- try {
149
- return !isAppManaged(resolveCommand());
150
- }
151
- catch {
152
- return false;
153
- }
154
- },
155
- /** Concurrent clicks share one installer process instead of racing writes. */
156
- update() {
157
- if (!active)
158
- active = perform().finally(() => (active = null));
159
- return active;
160
- },
161
- };
162
- }