@turns/dev 0.0.29 → 0.0.30
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/codex-update.js +55 -3
- package/dist/harness-exit.js +44 -0
- package/dist/index.js +18 -3
- package/dist/queue-worker.js +22 -0
- package/package.json +1 -1
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) ||
|
package/dist/codex-update.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** Machine-local Codex CLI self-update, callable by the Turns daemon. */
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
3
4
|
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { executablePath } from "./executable-path.js";
|
|
4
7
|
import { codexCommandIsAppManaged, resolveHarnessCommand } from "./harness-registry.js";
|
|
5
8
|
const VERSION_TIMEOUT_MS = 15_000;
|
|
6
9
|
const UPDATE_TIMEOUT_MS = 5 * 60_000;
|
|
@@ -11,7 +14,9 @@ function runCommand(command, args, timeoutMs) {
|
|
|
11
14
|
return new Promise((resolve) => {
|
|
12
15
|
const child = spawn(command, args, {
|
|
13
16
|
cwd: os.homedir(),
|
|
14
|
-
env
|
|
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() },
|
|
15
20
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
16
21
|
});
|
|
17
22
|
let output = '';
|
|
@@ -56,10 +61,54 @@ function commandFailure(result, fallback) {
|
|
|
56
61
|
.filter(Boolean);
|
|
57
62
|
return new Error(lines.at(-1) || fallback);
|
|
58
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
|
+
}
|
|
59
107
|
export function createCodexUpdateManager(dependencies = {}) {
|
|
60
108
|
const resolveCommand = dependencies.resolveCommand || (() => resolveHarnessCommand('codex'));
|
|
61
109
|
const isAppManaged = dependencies.isAppManaged || codexCommandIsAppManaged;
|
|
62
110
|
const run = dependencies.run || runCommand;
|
|
111
|
+
const upgradeCommand = dependencies.upgradeCommand || ((command) => codexUpgradeCommand(command));
|
|
63
112
|
let active = null;
|
|
64
113
|
const updateCommand = () => {
|
|
65
114
|
const command = resolveCommand();
|
|
@@ -74,9 +123,12 @@ export function createCodexUpdateManager(dependencies = {}) {
|
|
|
74
123
|
throw commandFailure(beforeRun, 'Codex is not installed or its version could not be read.');
|
|
75
124
|
}
|
|
76
125
|
const previousVersion = parseCodexVersion(beforeRun.output);
|
|
77
|
-
const
|
|
126
|
+
const upgrade = upgradeCommand(updateCommand());
|
|
127
|
+
const updateRun = await run(upgrade.command, upgrade.args, UPDATE_TIMEOUT_MS);
|
|
78
128
|
if (updateRun.failed || updateRun.timedOut || updateRun.exitCode !== 0) {
|
|
79
|
-
throw commandFailure(updateRun,
|
|
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.`);
|
|
80
132
|
}
|
|
81
133
|
// Resolve again because the updater may replace a shim or install path.
|
|
82
134
|
const afterRun = await run(updateCommand(), ['--version'], VERSION_TIMEOUT_MS);
|
|
@@ -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
|
+
function harnessLabel(harness) {
|
|
42
|
+
const name = String(harness || 'agent');
|
|
43
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
44
|
+
}
|
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/queue-worker.js
CHANGED
|
@@ -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,
|