@turns/dev 0.0.29 → 0.0.31

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.
@@ -15,6 +15,7 @@ import { recordLettaSessionContext } from "./letta-session-context.js";
15
15
  import { recoverAsyncQuestion } from "./codex-async-question-recovery.js";
16
16
  import { existingTranscriptPath } from "./compressed-transcripts.js";
17
17
  import { BackgroundTaskState } from "./background-task-state.js";
18
+ import { harnessExitError, StderrTail } from "./harness-exit.js";
18
19
  const socketIndex = process.argv.indexOf('--socket');
19
20
  const socketPath = socketIndex >= 0 ? path.resolve(process.argv[socketIndex + 1] || '') : '';
20
21
  if (!socketPath)
@@ -73,7 +74,7 @@ function writeLine(socket, value) {
73
74
  if (!socket.destroyed && socket.writable)
74
75
  socket.write(`${JSON.stringify(value)}\n`);
75
76
  }
76
- function childResult(child, timeoutMs) {
77
+ function childResult(child, timeoutMs, harness, stderrTail) {
77
78
  const result = deferred();
78
79
  let settled = false;
79
80
  const finish = (value) => {
@@ -84,7 +85,17 @@ function childResult(child, timeoutMs) {
84
85
  }
85
86
  };
86
87
  child.once('error', (error) => finish({ exitCode: null, signal: null, timedOut: false, error: { message: errorText(error) } }));
87
- child.once('close', (code, signal) => finish({ exitCode: code, signal, timedOut: false, error: null }));
88
+ child.once('close', (code, signal) => {
89
+ if (!code)
90
+ return finish({ exitCode: code, signal, timedOut: false, error: null });
91
+ const error = harnessExitError(harness, code, signal, stderrTail.lines());
92
+ finish({
93
+ exitCode: code,
94
+ signal,
95
+ timedOut: false,
96
+ error: { message: error.message, harnessExit: error.harnessExit },
97
+ });
98
+ });
88
99
  const timer = setTimeout(() => {
89
100
  try {
90
101
  process.kill(-child.pid, 'SIGTERM');
@@ -108,6 +119,7 @@ class LineTransport {
108
119
  pending;
109
120
  lastEventAt;
110
121
  child;
122
+ stderrTail = new StderrTail();
111
123
  constructor(anchor, harness, command, args, cwd, env = {}) {
112
124
  this.anchor = anchor;
113
125
  this.harness = harness;
@@ -129,14 +141,17 @@ class LineTransport {
129
141
  });
130
142
  anchor.child = this.child;
131
143
  readline.createInterface({ input: this.child.stdout }).on('line', (line) => this.receive(line));
132
- readline.createInterface({ input: this.child.stderr }).on('line', (line) => anchor.emitLive({
133
- event: 'process.line',
134
- source: `${harness}.stderr`,
135
- raw: line,
136
- phase: 'queued',
137
- }));
144
+ readline.createInterface({ input: this.child.stderr }).on('line', (line) => {
145
+ this.stderrTail.observe(line);
146
+ anchor.emitLive({
147
+ event: 'process.line',
148
+ source: `${harness}.stderr`,
149
+ raw: line,
150
+ phase: 'queued',
151
+ });
152
+ });
138
153
  this.child.once('close', (code, signal) => {
139
- const error = new Error(`${harness} transport exited with ${signal || code}`);
154
+ const error = harnessExitError(anchor.source, code, signal, this.stderrTail.lines());
140
155
  for (const waiter of this.pending.values())
141
156
  waiter.reject(error);
142
157
  this.pending.clear();
@@ -1772,7 +1787,12 @@ class Anchor {
1772
1787
  result: null,
1773
1788
  };
1774
1789
  this.setStatus('running');
1775
- void this.run(Boolean(request.createSession), this.turnTimeoutMs).then((result) => this.finish(result), (error) => this.finish({ exitCode: 1, signal: null, timedOut: false, error: { message: errorText(error) } }));
1790
+ void this.run(Boolean(request.createSession), this.turnTimeoutMs).then((result) => this.finish(result), (error) => this.finish({
1791
+ exitCode: 1,
1792
+ signal: null,
1793
+ timedOut: false,
1794
+ error: { message: errorText(error), ...(error?.harnessExit ? { harnessExit: error.harnessExit } : {}) },
1795
+ }));
1776
1796
  return { accepted: true, messageId: this.currentTurn.messageId, pid: process.pid };
1777
1797
  }
1778
1798
  async run(createSession, timeoutMs) {
@@ -1871,6 +1891,7 @@ class Anchor {
1871
1891
  this.child = child;
1872
1892
  if (invocation.stdin)
1873
1893
  child.stdin.end(`${invocation.stdin}\n`);
1894
+ const stderrTail = new StderrTail();
1874
1895
  for (const [stream, source] of [
1875
1896
  [child.stdout, `${this.source}.stdout`],
1876
1897
  [child.stderr, `${this.source}.stderr`],
@@ -1884,9 +1905,11 @@ class Anchor {
1884
1905
  const processSessionId = processEventSessionId(this.source, json);
1885
1906
  if (processSessionId && processSessionId !== this.agentSessionId)
1886
1907
  this.updateIdentity(processSessionId);
1908
+ if (stream === child.stderr)
1909
+ stderrTail.observe(line);
1887
1910
  this.emitLive({ event: 'process.line', source, raw: line, json, phase: 'queued' });
1888
1911
  });
1889
- return childResult(child, timeoutMs);
1912
+ return childResult(child, timeoutMs, this.source, stderrTail);
1890
1913
  }
1891
1914
  if (ACP_PROFILES[this.source]) {
1892
1915
  if (!(this.transport instanceof AcpTransport) ||
@@ -0,0 +1,44 @@
1
+ /** Failure details for a harness process that exits underneath Turns. */
2
+ const STDERR_TAIL_LINES = 12;
3
+ const STDERR_TAIL_CHARS = 4_000;
4
+ /** The last few stderr lines of a harness process. When the process exits on
5
+ * its own, they are usually the only explanation (for example, an outdated CLI
6
+ * rejecting an argument), and the turn's failure must carry them. */
7
+ export class StderrTail {
8
+ recent = [];
9
+ observe(line) {
10
+ const text = String(line)
11
+ .replace(/\u001b\[[0-9;?]*[A-Za-z]/gu, '')
12
+ .trimEnd();
13
+ if (!text.trim())
14
+ return;
15
+ this.recent.push(text.slice(0, STDERR_TAIL_CHARS));
16
+ if (this.recent.length > STDERR_TAIL_LINES)
17
+ this.recent.shift();
18
+ }
19
+ lines() {
20
+ const kept = [];
21
+ let size = 0;
22
+ for (const line of [...this.recent].reverse()) {
23
+ size += line.length + 1;
24
+ if (size > STDERR_TAIL_CHARS)
25
+ break;
26
+ kept.unshift(line);
27
+ }
28
+ return kept;
29
+ }
30
+ }
31
+ /** A harness process that ended underneath Turns rather than reporting a
32
+ * failure through its own protocol. */
33
+ export function harnessExitError(harness, code, signal, stderr) {
34
+ const label = harnessLabel(harness);
35
+ const how = signal ? `was stopped by ${signal}` : `exited with code ${code}`;
36
+ const message = [`${label} ${how} before the turn finished.`, ...(stderr.length ? ['', ...stderr] : [])].join('\n');
37
+ return Object.assign(new Error(message), {
38
+ harnessExit: { harness: String(harness), code: code ?? null, signal: signal ?? null, stderr },
39
+ });
40
+ }
41
+ export function harnessLabel(harness) {
42
+ const name = String(harness || 'agent');
43
+ return name.charAt(0).toUpperCase() + name.slice(1);
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
+ }
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import process from 'node:process';
5
6
  import { fileURLToPath } from 'node:url';
@@ -225,6 +226,22 @@ async function runUpload(credentials, { paths, watch = false, all = false, inter
225
226
  provider?.close();
226
227
  }
227
228
  }
229
+ /** First lines of `turns daemon run`, printed before authentication so a
230
+ * report pasted from a terminal or log always says which build and runtime
231
+ * produced it (`npx` can silently run an older cached release). */
232
+ export function daemonStartupBanner({ version, nodeVersion = process.version, platform = process.platform, arch = process.arch, osName = `${os.type()} ${os.release()}`, hostname = os.hostname(), pid = process.pid, packageDirectory = CLI_DIRECTORY, paths, allowedDirectories = [], }) {
233
+ return [
234
+ `Turns daemon ${version}`,
235
+ ` Node ${nodeVersion} on ${platform}-${arch} (${osName}), host ${hostname}, pid ${pid}`,
236
+ ` Package ${packageDirectory}`,
237
+ ` Config ${paths.configDirectory}`,
238
+ ` State ${paths.stateDirectory}`,
239
+ allowedDirectories.length
240
+ ? ` Directories:\n${allowedDirectories.map((value) => ` ${value}`).join('\n')}`
241
+ : ' Directories: all',
242
+ '',
243
+ ].join('\n');
244
+ }
228
245
  async function main(rawArguments = process.argv.slice(2)) {
229
246
  const parsed = parseGlobalArguments(rawArguments);
230
247
  const argv = parsed.argv;
@@ -274,6 +291,7 @@ async function main(rawArguments = process.argv.slice(2)) {
274
291
  throw new Error('--once cannot be combined with `turns daemon run`.');
275
292
  if (parsed.devUser && !parsed.origin)
276
293
  throw new Error('--dev-user requires an explicit --server URL.');
294
+ logInfo(daemonStartupBanner({ version: pkg.version, paths, allowedDirectories }));
277
295
  let credentials;
278
296
  if (parsed.devUser) {
279
297
  credentials = { origin: normalizeOrigin(parsed.origin) };
@@ -282,9 +300,6 @@ async function main(rawArguments = process.argv.slice(2)) {
282
300
  await migrateLegacyCredentials(paths, parsed.origin);
283
301
  credentials = await authenticate({ origin: parsed.origin, paths, interactive: false });
284
302
  }
285
- if (allowedDirectories.length) {
286
- logInfo(`Daemon directory allowlist:\n${allowedDirectories.map((value) => ` ${value}`).join('\n')}\n`);
287
- }
288
303
  await runUpload(credentials, {
289
304
  paths,
290
305
  watch: true,
@@ -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
  }
@@ -7,7 +7,7 @@ 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
9
  import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentTurn } from "./agent-anchor-client.js";
10
- import { createCodexUpdateManager } from "./codex-update.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";
@@ -301,6 +301,28 @@ export function runtimeEvent(anchorName, messageId, event, redactor = DEFAULT_RE
301
301
  value: redactor.redactJsonLine(JSON.stringify(event)),
302
302
  };
303
303
  }
304
+ // A harness that exits underneath its turn often reports the reason only on
305
+ // stderr, before any protocol frame the reducer could show. Record that
306
+ // ending explicitly so the transcript can explain the failed turn.
307
+ if (event?.event === 'turn.complete' && event.result?.error?.harnessExit && !event.result.interrupted) {
308
+ const exit = event.result.error.harnessExit;
309
+ return {
310
+ eventKey: key,
311
+ kind: 'session.harness_exit',
312
+ phase: 'queued',
313
+ ts,
314
+ source: 'agent-anchor',
315
+ value: redactor.redactJsonLine(JSON.stringify({
316
+ type: 'turns.session.harness_exit',
317
+ messageId: String(event.messageId || messageId || ''),
318
+ harness: String(exit.harness || event.agent || ''),
319
+ code: typeof exit.code === 'number' ? exit.code : null,
320
+ signal: exit.signal ? String(exit.signal) : null,
321
+ message: String(event.result.error.message || ''),
322
+ stderr: Array.isArray(exit.stderr) ? exit.stderr.map(String) : [],
323
+ })),
324
+ };
325
+ }
304
326
  if (event?.event === 'turn.steer') {
305
327
  return {
306
328
  eventKey: key,
@@ -319,6 +341,14 @@ export function runtimeEvent(anchorName, messageId, event, redactor = DEFAULT_RE
319
341
  }
320
342
  return null;
321
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
+ }
322
352
  export function resultError(result) {
323
353
  if (!result)
324
354
  return new Error('The anchored agent returned no result.');
@@ -1317,18 +1347,22 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1317
1347
  });
1318
1348
  }
1319
1349
  };
1320
- /** Upgrade the machine's Codex installation. Only this fixed, resolved
1321
- * command is exposed; the browser cannot supply a binary, arguments, or
1322
- * shell input. Errors are redacted before crossing the machine boundary. */
1323
- const codexUpdate = createCodexUpdateManager();
1324
- const handleCodexUpdate = async (message, replyRpc) => {
1350
+ /** Upgrade one of the machine's harness CLIs. Only a fixed command chosen
1351
+ * from the resolved executable's install location is run; the browser names
1352
+ * a harness but cannot supply a binary, arguments, or shell input. Errors
1353
+ * are redacted before crossing the machine boundary. The message keeps its
1354
+ * Codex-era name; a request without a harness is from a Codex-only backend. */
1355
+ const harnessUpdate = createHarnessUpdateManager();
1356
+ const handleHarnessUpdate = async (message, replyRpc) => {
1325
1357
  const sessionId = String(message.sessionId || '');
1358
+ const harness = String(message.harness || 'codex');
1326
1359
  try {
1327
- const answer = await codexUpdate.update();
1360
+ const answer = await harnessUpdate.update(harness);
1328
1361
  replyRpc.notify({
1329
1362
  type: 'session.codex.update.result',
1330
1363
  requestId: message.requestId,
1331
1364
  sessionId,
1365
+ harness,
1332
1366
  ok: true,
1333
1367
  answer,
1334
1368
  });
@@ -1338,6 +1372,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1338
1372
  type: 'session.codex.update.result',
1339
1373
  requestId: message.requestId,
1340
1374
  sessionId,
1375
+ harness,
1341
1376
  ok: false,
1342
1377
  error: redactor.redactText(error?.message || String(error)),
1343
1378
  });
@@ -1402,33 +1437,44 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1402
1437
  }
1403
1438
  }, HARNESS_HEARTBEAT_MS);
1404
1439
  heartbeat.unref?.();
1440
+ // A browser request is answered over the same socket it arrived on. When
1441
+ // that socket drops mid-request, the handler's reply throws, and so does the
1442
+ // error reply in its catch block. Nothing awaits these handlers, so that
1443
+ // second throw used to surface as an unhandled rejection and take the whole
1444
+ // daemon down with every running session's watcher. The browser has already
1445
+ // been told its connection closed; there is nobody left to answer.
1446
+ const answer = (handler, message) => {
1447
+ void Promise.resolve()
1448
+ .then(() => handler(message, rpc))
1449
+ .catch((error) => logError(`Could not answer ${message.type}: ${error?.message || error}\n`));
1450
+ };
1405
1451
  rpc.onEvent((message) => {
1406
1452
  if (lifecycle.stopping)
1407
1453
  return;
1408
1454
  if (message.type === 'queue.ready')
1409
1455
  void tryClaim(message.sessionId);
1410
1456
  if (message.type === 'session.steer')
1411
- void handleSteer(message, rpc);
1457
+ answer(handleSteer, message);
1412
1458
  if (message.type === 'session.interrupt.request')
1413
- void handleInterrupt(message, rpc);
1459
+ answer(handleInterrupt, message);
1414
1460
  if (message.type === 'session.anchor.action.request')
1415
- void handleAnchorAction(message, rpc);
1461
+ answer(handleAnchorAction, message);
1416
1462
  if (message.type === 'session.interaction.answer.request')
1417
- void handleInteractionAnswer(message, rpc);
1463
+ answer(handleInteractionAnswer, message);
1418
1464
  if (message.type === 'session.file.browse.request')
1419
- void handleFileBrowse(message, rpc);
1465
+ answer(handleFileBrowse, message);
1420
1466
  if (message.type === 'session.file.range.request')
1421
- void handleFileRange(message, rpc);
1467
+ answer(handleFileRange, message);
1422
1468
  if (message.type === 'session.file.search.request')
1423
- void handleFileSearch(message, rpc);
1469
+ answer(handleFileSearch, message);
1424
1470
  if (message.type === 'session.git.read.request')
1425
- void handleGitRead(message, rpc);
1471
+ answer(handleGitRead, message);
1426
1472
  if (message.type === 'session.harness.auth.request')
1427
- void handleHarnessAuth(message, rpc);
1473
+ answer(handleHarnessAuth, message);
1428
1474
  if (message.type === 'session.codex.update.request')
1429
- void handleCodexUpdate(message, rpc);
1475
+ answer(handleHarnessUpdate, message);
1430
1476
  if (message.type === 'session.file.edit.request')
1431
- void handleFileEdit(message, rpc);
1477
+ answer(handleFileEdit, message);
1432
1478
  });
1433
1479
  const registered = new WeakSet();
1434
1480
  const registering = new WeakSet();
@@ -1447,7 +1493,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1447
1493
  git: true,
1448
1494
  fileRanges: true,
1449
1495
  harnessAuth: true,
1450
- ...(codexUpdate.available() ? { codexUpdate: true } : {}),
1496
+ ...updateCapabilities(harnessUpdate.available()),
1451
1497
  },
1452
1498
  workerId,
1453
1499
  workerInstanceId,
@@ -1468,7 +1514,15 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1468
1514
  rpc.onConnect((control) => {
1469
1515
  void registerControl(control);
1470
1516
  });
1471
- await registerControl(await rpc.current());
1517
+ // Starting offline must not end this function: the loop below is the only
1518
+ // thing that sends heartbeats, probes for a half-open socket and re-polls
1519
+ // for missed queue events, and it already tolerates a failed connection.
1520
+ try {
1521
+ await registerControl(await rpc.current());
1522
+ }
1523
+ catch (error) {
1524
+ logError(`Queue worker could not connect yet: ${error?.message || error}\n`);
1525
+ }
1472
1526
  while (!lifecycle.stopping) {
1473
1527
  await backgroundWait(MACHINE_HEARTBEAT_MS);
1474
1528
  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,
@@ -638,6 +669,10 @@ export class RpcSocket {
638
669
  this.socket.on('message', (data) => this.onMessage(String(data), (ready) => settle(resolve, ready), (error) => settle(reject, error)));
639
670
  this.socket.on('close', (code, reason) => {
640
671
  const error = socketError(`WebSocket closed (${code}): ${String(reason)}`);
672
+ // A server that accepts the upgrade and closes before saying "ready"
673
+ // (a deploy, a restarting object) has answered. Without this the
674
+ // connect waited out the whole ready deadline before it could retry.
675
+ settle(reject, error);
641
676
  for (const request of this.pending.values()) {
642
677
  clearTimeout(request.timer);
643
678
  request.reject(error);
@@ -1087,13 +1122,27 @@ export async function uploadNativeSession(rpc, machine, descriptor, cursor = {},
1087
1122
  if (!held.length)
1088
1123
  return;
1089
1124
  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
- });
1125
+ let acknowledgement;
1126
+ try {
1127
+ acknowledgement = await rpc.request({
1128
+ type: 'ingest',
1129
+ sessionId: uploadSessionId,
1130
+ startRow: rows[0].index,
1131
+ rows,
1132
+ ...(initialUpload ? { deferReduction: true } : {}),
1133
+ });
1134
+ }
1135
+ catch (error) {
1136
+ // The connection resends a request whose socket died. When only the
1137
+ // acknowledgement was lost, the server already holds these rows and
1138
+ // answers the resend with the offset it has reached. That is an
1139
+ // acknowledgement: treating it as a failure put the session into backoff
1140
+ // and then re-read its whole transcript from the first byte.
1141
+ const acknowledged = ingestOffsetAcknowledges(error, rows[0].index, rows.length);
1142
+ if (acknowledged === null)
1143
+ throw error;
1144
+ acknowledgement = { nextRow: acknowledged };
1145
+ }
1097
1146
  nextRow = Number(acknowledgement.nextRow);
1098
1147
  sent += rows.length;
1099
1148
  batchesSent += 1;
@@ -1624,6 +1673,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1624
1673
  try {
1625
1674
  const stat = fs.statSync(descriptor.file);
1626
1675
  physicalStats.set(sessionId, { size: stat.size, mtimeMs: stat.mtimeMs });
1676
+ adoptGrowthSinceInventory(descriptor, stat);
1627
1677
  }
1628
1678
  catch { }
1629
1679
  const saved = state.get(sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,110 +0,0 @@
1
- /** Machine-local Codex CLI self-update, callable by the Turns daemon. */
2
- import { spawn } from 'node:child_process';
3
- import os from 'node:os';
4
- import { codexCommandIsAppManaged, resolveHarnessCommand } from "./harness-registry.js";
5
- const VERSION_TIMEOUT_MS = 15_000;
6
- const UPDATE_TIMEOUT_MS = 5 * 60_000;
7
- const OUTPUT_LIMIT = 64 * 1024;
8
- /** Run the resolved executable directly—never through a shell—so update
9
- * requests cannot become arbitrary command execution. */
10
- function runCommand(command, args, timeoutMs) {
11
- return new Promise((resolve) => {
12
- const child = spawn(command, args, {
13
- cwd: os.homedir(),
14
- env: process.env,
15
- stdio: ['ignore', 'pipe', 'pipe'],
16
- });
17
- let output = '';
18
- let settled = false;
19
- const settle = (result) => {
20
- if (settled)
21
- return;
22
- settled = true;
23
- clearTimeout(timer);
24
- resolve(result);
25
- };
26
- const timer = setTimeout(() => {
27
- try {
28
- child.kill('SIGKILL');
29
- }
30
- catch { }
31
- settle({ output, exitCode: null, timedOut: true });
32
- }, timeoutMs);
33
- timer.unref?.();
34
- const collect = (chunk) => {
35
- if (output.length < OUTPUT_LIMIT)
36
- output += String(chunk).slice(0, OUTPUT_LIMIT - output.length);
37
- };
38
- child.stdout.on('data', collect);
39
- child.stderr.on('data', collect);
40
- child.on('error', () => settle({ output, exitCode: null, failed: true }));
41
- child.on('close', (exitCode) => settle({ output, exitCode }));
42
- });
43
- }
44
- export function parseCodexVersion(output) {
45
- const text = String(output || '').trim();
46
- const prefixed = text.match(/codex(?:-cli)?\s+([^\s]+)/iu);
47
- return (prefixed?.[1] || text.split(/\s+/u).at(-1) || '').slice(0, 120);
48
- }
49
- function commandFailure(result, fallback) {
50
- if (result.timedOut)
51
- return new Error('The Codex update timed out.');
52
- const lines = result.output
53
- .replace(/\u001b\[[0-9;?]*[A-Za-z]/gu, '')
54
- .split('\n')
55
- .map((line) => line.trim())
56
- .filter(Boolean);
57
- return new Error(lines.at(-1) || fallback);
58
- }
59
- export function createCodexUpdateManager(dependencies = {}) {
60
- const resolveCommand = dependencies.resolveCommand || (() => resolveHarnessCommand('codex'));
61
- const isAppManaged = dependencies.isAppManaged || codexCommandIsAppManaged;
62
- const run = dependencies.run || runCommand;
63
- let active = null;
64
- const updateCommand = () => {
65
- const command = resolveCommand();
66
- if (isAppManaged(command)) {
67
- throw new Error('Codex is managed by the macOS app. Update the Codex app to update its bundled CLI.');
68
- }
69
- return command;
70
- };
71
- const perform = async () => {
72
- const beforeRun = await run(updateCommand(), ['--version'], VERSION_TIMEOUT_MS);
73
- if (beforeRun.failed || beforeRun.timedOut || beforeRun.exitCode !== 0) {
74
- throw commandFailure(beforeRun, 'Codex is not installed or its version could not be read.');
75
- }
76
- const previousVersion = parseCodexVersion(beforeRun.output);
77
- const updateRun = await run(updateCommand(), ['update'], UPDATE_TIMEOUT_MS);
78
- if (updateRun.failed || updateRun.timedOut || updateRun.exitCode !== 0) {
79
- throw commandFailure(updateRun, 'Codex could not update itself. Run `codex update` in a terminal on the machine.');
80
- }
81
- // Resolve again because the updater may replace a shim or install path.
82
- const afterRun = await run(updateCommand(), ['--version'], VERSION_TIMEOUT_MS);
83
- if (afterRun.failed || afterRun.timedOut || afterRun.exitCode !== 0) {
84
- throw commandFailure(afterRun, 'Codex updated, but its new version could not be read.');
85
- }
86
- const currentVersion = parseCodexVersion(afterRun.output);
87
- return {
88
- ...(previousVersion ? { previousVersion } : {}),
89
- ...(currentVersion ? { currentVersion } : {}),
90
- updated: Boolean(previousVersion && currentVersion && previousVersion !== currentVersion),
91
- };
92
- };
93
- return {
94
- /** App-bundled Codex is updated with the app, not by its CLI command. */
95
- available() {
96
- try {
97
- return !isAppManaged(resolveCommand());
98
- }
99
- catch {
100
- return false;
101
- }
102
- },
103
- /** Concurrent clicks share one installer process instead of racing writes. */
104
- update() {
105
- if (!active)
106
- active = perform().finally(() => (active = null));
107
- return active;
108
- },
109
- };
110
- }