@turns/dev 0.0.30 → 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.
@@ -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
+ }
@@ -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";
@@ -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.');
@@ -1339,18 +1347,22 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1339
1347
  });
1340
1348
  }
1341
1349
  };
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) => {
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) => {
1347
1357
  const sessionId = String(message.sessionId || '');
1358
+ const harness = String(message.harness || 'codex');
1348
1359
  try {
1349
- const answer = await codexUpdate.update();
1360
+ const answer = await harnessUpdate.update(harness);
1350
1361
  replyRpc.notify({
1351
1362
  type: 'session.codex.update.result',
1352
1363
  requestId: message.requestId,
1353
1364
  sessionId,
1365
+ harness,
1354
1366
  ok: true,
1355
1367
  answer,
1356
1368
  });
@@ -1360,6 +1372,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1360
1372
  type: 'session.codex.update.result',
1361
1373
  requestId: message.requestId,
1362
1374
  sessionId,
1375
+ harness,
1363
1376
  ok: false,
1364
1377
  error: redactor.redactText(error?.message || String(error)),
1365
1378
  });
@@ -1424,33 +1437,44 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1424
1437
  }
1425
1438
  }, HARNESS_HEARTBEAT_MS);
1426
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
+ };
1427
1451
  rpc.onEvent((message) => {
1428
1452
  if (lifecycle.stopping)
1429
1453
  return;
1430
1454
  if (message.type === 'queue.ready')
1431
1455
  void tryClaim(message.sessionId);
1432
1456
  if (message.type === 'session.steer')
1433
- void handleSteer(message, rpc);
1457
+ answer(handleSteer, message);
1434
1458
  if (message.type === 'session.interrupt.request')
1435
- void handleInterrupt(message, rpc);
1459
+ answer(handleInterrupt, message);
1436
1460
  if (message.type === 'session.anchor.action.request')
1437
- void handleAnchorAction(message, rpc);
1461
+ answer(handleAnchorAction, message);
1438
1462
  if (message.type === 'session.interaction.answer.request')
1439
- void handleInteractionAnswer(message, rpc);
1463
+ answer(handleInteractionAnswer, message);
1440
1464
  if (message.type === 'session.file.browse.request')
1441
- void handleFileBrowse(message, rpc);
1465
+ answer(handleFileBrowse, message);
1442
1466
  if (message.type === 'session.file.range.request')
1443
- void handleFileRange(message, rpc);
1467
+ answer(handleFileRange, message);
1444
1468
  if (message.type === 'session.file.search.request')
1445
- void handleFileSearch(message, rpc);
1469
+ answer(handleFileSearch, message);
1446
1470
  if (message.type === 'session.git.read.request')
1447
- void handleGitRead(message, rpc);
1471
+ answer(handleGitRead, message);
1448
1472
  if (message.type === 'session.harness.auth.request')
1449
- void handleHarnessAuth(message, rpc);
1473
+ answer(handleHarnessAuth, message);
1450
1474
  if (message.type === 'session.codex.update.request')
1451
- void handleCodexUpdate(message, rpc);
1475
+ answer(handleHarnessUpdate, message);
1452
1476
  if (message.type === 'session.file.edit.request')
1453
- void handleFileEdit(message, rpc);
1477
+ answer(handleFileEdit, message);
1454
1478
  });
1455
1479
  const registered = new WeakSet();
1456
1480
  const registering = new WeakSet();
@@ -1469,7 +1493,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1469
1493
  git: true,
1470
1494
  fileRanges: true,
1471
1495
  harnessAuth: true,
1472
- ...(codexUpdate.available() ? { codexUpdate: true } : {}),
1496
+ ...updateCapabilities(harnessUpdate.available()),
1473
1497
  },
1474
1498
  workerId,
1475
1499
  workerInstanceId,
@@ -1490,7 +1514,15 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1490
1514
  rpc.onConnect((control) => {
1491
1515
  void registerControl(control);
1492
1516
  });
1493
- 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
+ }
1494
1526
  while (!lifecycle.stopping) {
1495
1527
  await backgroundWait(MACHINE_HEARTBEAT_MS);
1496
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.30",
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,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
- }