@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.
- package/dist/agent-anchor.js +34 -11
- package/dist/harness-exit.js +44 -0
- package/dist/harness-update.js +236 -0
- package/dist/index.js +18 -3
- package/dist/path-resolution.js +5 -1
- package/dist/queue-worker.js +74 -20
- package/dist/upload-sessions.js +57 -7
- package/package.json +1 -1
- package/dist/codex-update.js +0 -110
package/dist/agent-anchor.js
CHANGED
|
@@ -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) =>
|
|
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) =>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 =
|
|
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({
|
|
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,
|
package/dist/path-resolution.js
CHANGED
|
@@ -87,7 +87,11 @@ export function pathCandidates(row) {
|
|
|
87
87
|
continue;
|
|
88
88
|
if (found.includes(candidate))
|
|
89
89
|
continue;
|
|
90
|
-
|
|
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
|
}
|
package/dist/queue-worker.js
CHANGED
|
@@ -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 {
|
|
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
|
|
1321
|
-
*
|
|
1322
|
-
*
|
|
1323
|
-
|
|
1324
|
-
|
|
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
|
|
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
|
-
|
|
1457
|
+
answer(handleSteer, message);
|
|
1412
1458
|
if (message.type === 'session.interrupt.request')
|
|
1413
|
-
|
|
1459
|
+
answer(handleInterrupt, message);
|
|
1414
1460
|
if (message.type === 'session.anchor.action.request')
|
|
1415
|
-
|
|
1461
|
+
answer(handleAnchorAction, message);
|
|
1416
1462
|
if (message.type === 'session.interaction.answer.request')
|
|
1417
|
-
|
|
1463
|
+
answer(handleInteractionAnswer, message);
|
|
1418
1464
|
if (message.type === 'session.file.browse.request')
|
|
1419
|
-
|
|
1465
|
+
answer(handleFileBrowse, message);
|
|
1420
1466
|
if (message.type === 'session.file.range.request')
|
|
1421
|
-
|
|
1467
|
+
answer(handleFileRange, message);
|
|
1422
1468
|
if (message.type === 'session.file.search.request')
|
|
1423
|
-
|
|
1469
|
+
answer(handleFileSearch, message);
|
|
1424
1470
|
if (message.type === 'session.git.read.request')
|
|
1425
|
-
|
|
1471
|
+
answer(handleGitRead, message);
|
|
1426
1472
|
if (message.type === 'session.harness.auth.request')
|
|
1427
|
-
|
|
1473
|
+
answer(handleHarnessAuth, message);
|
|
1428
1474
|
if (message.type === 'session.codex.update.request')
|
|
1429
|
-
|
|
1475
|
+
answer(handleHarnessUpdate, message);
|
|
1430
1476
|
if (message.type === 'session.file.edit.request')
|
|
1431
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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)
|
package/dist/upload-sessions.js
CHANGED
|
@@ -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
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
package/dist/codex-update.js
DELETED
|
@@ -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
|
-
}
|