@turns/dev 0.0.31 → 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')
@@ -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
  }
@@ -6,7 +6,7 @@ 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";
9
+ import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, STATUS_SNAPSHOT, waitForAgentTurn, } from "./agent-anchor-client.js";
10
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";
@@ -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
@@ -379,7 +379,7 @@ export function queueFailureIsRetryable(error, promptStarted) {
379
379
  export async function actualHarnessStatus(entry) {
380
380
  if (!entry || entry.connection.closed)
381
381
  return 'stopped';
382
- const snapshot = await entry.connection.request('snapshot', {}, 5_000);
382
+ const snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000);
383
383
  entry.snapshot = snapshot;
384
384
  return snapshot.status === 'running' ? 'running' : 'idle';
385
385
  }
@@ -594,7 +594,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
594
594
  const usableAnchor = async (sessionId, source, agentSessionId, modelSettings = false) => {
595
595
  let entry = anchors.get(sessionId);
596
596
  if (entry && !entry.connection.closed) {
597
- 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);
598
598
  if (String(entry.snapshot.source || '') === source &&
599
599
  String(entry.snapshot.agentSessionId || '') === agentSessionId &&
600
600
  (!modelSettings || entry.snapshot.capabilities?.includes('model_settings')))
@@ -843,7 +843,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
843
843
  idleTimeoutMs: QUEUE_WARM_MS,
844
844
  }, 30_000);
845
845
  });
846
- 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);
847
847
  // Registration after acceptance is safe: the connection retains recent
848
848
  // events and the snapshot carries a turn that completed immediately.
849
849
  result = await waitForAgentTurn(entry.connection, claim.prompt.id, QUEUE_TURN_TIMEOUT_MS, entry.snapshot, eventCursor);
@@ -852,7 +852,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
852
852
  await request({ type: 'session.native.bind', sessionId, agentSessionId: createdIdentity });
853
853
  }
854
854
  }
855
- entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => ({
855
+ entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => ({
856
856
  ...entry.snapshot,
857
857
  status: 'idle',
858
858
  }));
@@ -888,7 +888,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
888
888
  stopClaimRenewal(held);
889
889
  if (held?.entry && !held.entry.connection.closed) {
890
890
  held.entry.snapshot = await held.entry.connection
891
- .request('snapshot', {}, 2_000)
891
+ .request('snapshot', STATUS_SNAPSHOT, 2_000)
892
892
  .catch(() => held.entry.snapshot);
893
893
  await reportHarness(sessionId, held.entry.snapshot?.status === 'running' ? 'running' : 'idle', held.entry).catch(() => { });
894
894
  }
@@ -915,8 +915,15 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
915
915
  };
916
916
  const recoverAnchors = async () => {
917
917
  for (const [sessionId, entry] of anchors) {
918
- const snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
919
- 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: [] };
920
927
  await reportHarness(sessionId, snapshot.status === 'running' ? 'running' : 'idle', entry).catch(() => { });
921
928
  const turn = snapshot.currentTurn;
922
929
  // A warm anchor retains its terminal turn for diagnostics. Probe the
@@ -1047,7 +1054,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1047
1054
  try {
1048
1055
  if (!entry || entry.connection.closed)
1049
1056
  throw new Error('The detached harness is offline.');
1050
- entry.snapshot = await entry.connection.request('snapshot', {}, 5_000);
1057
+ entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000);
1051
1058
  if (entry.snapshot?.status === 'running')
1052
1059
  throw new Error('The detached harness is actively running.');
1053
1060
  if (action === 'compact' && entry.snapshot?.pendingInteractions?.length) {
@@ -1056,7 +1063,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1056
1063
  await entry.connection.request(action, {}, action === 'compact' ? QUEUE_TURN_TIMEOUT_MS : 5_000);
1057
1064
  if (action === 'compact') {
1058
1065
  await flushRuntime(sessionId);
1059
- 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);
1060
1067
  await reportHarness(sessionId, 'idle', entry);
1061
1068
  }
1062
1069
  else {
@@ -481,32 +481,39 @@ function nativeTimestamp(raw, descriptor, rowIndex) {
481
481
  const base = Date.parse(descriptor.createdAt) || descriptor.mtimeMs || Date.now();
482
482
  return new Date(base + rowIndex).toISOString();
483
483
  }
484
- async function* completeNativeFileLines(file, startOffset, endOffset) {
484
+ export async function* completeNativeFileLines(file, startOffset, endOffset) {
485
485
  if (endOffset < startOffset)
486
486
  return;
487
- let carry = Buffer.alloc(0);
488
- 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;
489
493
  const input = fs.createReadStream(file, { start: startOffset, end: endOffset });
490
494
  try {
491
495
  for await (const chunk of input) {
492
- const buffer = carry.length ? Buffer.concat([carry, chunk]) : chunk;
493
- const bufferOffset = carryOffset;
494
496
  let lineStart = 0;
495
497
  for (;;) {
496
- 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);
497
500
  if (newline < 0)
498
501
  break;
499
- 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;
500
507
  if (line.at(-1) === 0x0d)
501
508
  line = line.subarray(0, -1);
502
- yield {
503
- raw: line.toString('utf8'),
504
- endOffset: bufferOffset + newline + 1,
505
- };
509
+ yield { raw: line.toString('utf8'), endOffset: consumed };
506
510
  lineStart = newline + 1;
507
511
  }
508
- carry = buffer.subarray(lineStart);
509
- carryOffset = bufferOffset + lineStart;
512
+ if (lineStart < chunk.length) {
513
+ const rest = chunk.subarray(lineStart);
514
+ carried.push(rest);
515
+ carriedBytes += rest.length;
516
+ }
510
517
  }
511
518
  }
512
519
  finally {
@@ -2068,6 +2075,10 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
2068
2075
  String(descriptor.parentSessionId || ''),
2069
2076
  NATIVE_DESCRIPTOR_VERSION,
2070
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)] : []),
2071
2082
  ]);
2072
2083
  }
2073
2084
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.31",
3
+ "version": "0.0.32",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",