@proagentstore/cli 0.4.66 → 0.4.68
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/browser-runner/coding/engine-acts.js +11 -3
- package/dist/browser-runner/coding/engine-adapter.js +15 -1
- package/dist/browser-runner/coding/engine-check.js +114 -0
- package/dist/browser-runner/coding/engine-turn.js +29 -4
- package/dist/browser-runner/coding/headless.js +47 -7
- package/dist/browser-runner/coding/heredoc.js +103 -0
- package/dist/browser-runner/server.js +13 -0
- package/package.json +1 -1
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* A raw engine emits nothing parseable, so an empty act list means "nothing observed", never
|
|
35
35
|
* "nothing happened", and every consumer has to say so rather than render it as an all-clear.
|
|
36
36
|
*/
|
|
37
|
+
import { splitHeredocs } from "./heredoc.js";
|
|
37
38
|
/** Acts whose consequences reach outside the machine, or cannot be walked back locally. */
|
|
38
39
|
const IRREVERSIBLE = new Set([
|
|
39
40
|
"pr.merge",
|
|
@@ -77,10 +78,15 @@ export function redactCommand(command) {
|
|
|
77
78
|
* Over-splitting costs nothing (a fragment simply matches no rule); under-splitting loses an act,
|
|
78
79
|
* and the real-world shape of the case this exists for is exactly a compound line:
|
|
79
80
|
* `cd repo && git push -u origin fix && gh pr create --fill && gh pr merge --squash`.
|
|
81
|
+
*
|
|
82
|
+
* Heredoc BODIES are removed first (#873): they are data — a commit message, a comment body, a
|
|
83
|
+
* script editing a test file — and their lines, split as commands, recorded pushes, merges and a
|
|
84
|
+
* repository deletion that never ran. This side sees the whole command, so a real command after
|
|
85
|
+
* the terminator is still read.
|
|
80
86
|
*/
|
|
81
87
|
export function splitSegments(command) {
|
|
82
|
-
return String(command ?? "")
|
|
83
|
-
.split(/\|\||&&|[;|\n]/)
|
|
88
|
+
return splitHeredocs(String(command ?? ""))
|
|
89
|
+
.executed.split(/\|\||&&|[;|\n]/)
|
|
84
90
|
.map((s) => s.trim())
|
|
85
91
|
.filter(Boolean);
|
|
86
92
|
}
|
|
@@ -265,7 +271,9 @@ export function classifySegment(segment) {
|
|
|
265
271
|
* was on that line.
|
|
266
272
|
*/
|
|
267
273
|
export function classifyCommand(id, command, at = new Date().toISOString()) {
|
|
268
|
-
|
|
274
|
+
// The evidence keeps what was executed and collapses each heredoc body to one line (#873), so
|
|
275
|
+
// the 400-character cap is not spent on a commit message while the push after it is cut off.
|
|
276
|
+
const full = redactCommand(splitHeredocs(command).elided);
|
|
269
277
|
if (!full)
|
|
270
278
|
return [];
|
|
271
279
|
const out = [];
|
|
@@ -156,9 +156,23 @@ function parseCodexLine(line) {
|
|
|
156
156
|
if (type === "thread.started" && typeof ev.thread_id === "string" && ev.thread_id)
|
|
157
157
|
return [{ kind: "session", sessionId: ev.thread_id }];
|
|
158
158
|
if (type === "turn.completed" || type === "turn.failed") {
|
|
159
|
-
|
|
159
|
+
// Codex sends `error` as an OBJECT — `{"type":"turn.failed","error":{"message":"…401…"}}` — and
|
|
160
|
+
// reading only the string form reduced a 401 to the word "failed" (#889).
|
|
161
|
+
const result = typeof ev.error === "string"
|
|
162
|
+
? ev.error
|
|
163
|
+
: typeof record(ev.error)?.message === "string"
|
|
164
|
+
? record(ev.error)?.message
|
|
165
|
+
: typeof ev.message === "string"
|
|
166
|
+
? ev.message
|
|
167
|
+
: type === "turn.failed"
|
|
168
|
+
? "failed"
|
|
169
|
+
: "";
|
|
160
170
|
return [{ kind: "turn_end", raw: ev, isError: type === "turn.failed", result }];
|
|
161
171
|
}
|
|
172
|
+
if (type === "error") {
|
|
173
|
+
const text = typeof ev.message === "string" ? ev.message : typeof record(ev.error)?.message === "string" ? record(ev.error)?.message : "";
|
|
174
|
+
return text.trim() ? [{ kind: "engine_error", text: text.trim() }] : [];
|
|
175
|
+
}
|
|
162
176
|
if (!item)
|
|
163
177
|
return [];
|
|
164
178
|
const itemType = typeof item.type === "string" ? item.type : "";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { handlerFor } from "./handlers.js";
|
|
6
|
+
import { parseCommand } from "./headless.js";
|
|
7
|
+
function defaultIsExecutable(p) {
|
|
8
|
+
try {
|
|
9
|
+
if (!statSync(p).isFile())
|
|
10
|
+
return false;
|
|
11
|
+
accessSync(p, constants.X_OK);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function defaultExists(p) {
|
|
19
|
+
try {
|
|
20
|
+
statSync(p);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function defaultKeychainItemStatus(service) {
|
|
28
|
+
// Existence only: without `-w`/`-g` the secret is never read, so this cannot prompt or leak.
|
|
29
|
+
const r = spawnSync("security", ["find-generic-password", "-s", service], { stdio: "ignore", timeout: 5_000 });
|
|
30
|
+
return r.error ? null : r.status;
|
|
31
|
+
}
|
|
32
|
+
/** The same resolution `spawn(bin)` does: a path is taken as-is, a bare name is searched on PATH. */
|
|
33
|
+
export function resolveBinary(bin, deps = {}) {
|
|
34
|
+
const isExec = deps.isExecutable ?? defaultIsExecutable;
|
|
35
|
+
if (!bin)
|
|
36
|
+
return null;
|
|
37
|
+
if (bin.includes("/") || isAbsolute(bin)) {
|
|
38
|
+
const p = resolve(bin);
|
|
39
|
+
return isExec(p) ? p : null;
|
|
40
|
+
}
|
|
41
|
+
const env = deps.env ?? process.env;
|
|
42
|
+
const exts = (deps.platform ?? process.platform) === "win32" ? (env.PATHEXT || ".EXE;.CMD;.BAT").split(";") : [""];
|
|
43
|
+
for (const dir of (env.PATH || "").split(delimiter)) {
|
|
44
|
+
if (!dir)
|
|
45
|
+
continue;
|
|
46
|
+
for (const ext of exts) {
|
|
47
|
+
const p = join(dir, bin + ext);
|
|
48
|
+
if (isExec(p))
|
|
49
|
+
return p;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/** Does this engine have a stored interactive login on the machine? */
|
|
55
|
+
export function detectEngineLogin(engine, deps = {}) {
|
|
56
|
+
const env = deps.env ?? process.env;
|
|
57
|
+
const home = deps.home ?? homedir();
|
|
58
|
+
const exists = deps.exists ?? defaultExists;
|
|
59
|
+
const platform = deps.platform ?? process.platform;
|
|
60
|
+
if (engine === "codex") {
|
|
61
|
+
// `codex login` (ChatGPT subscription) writes auth.json under CODEX_HOME, default ~/.codex.
|
|
62
|
+
const dir = env.CODEX_HOME?.trim() || join(home, ".codex");
|
|
63
|
+
const file = join(dir, "auth.json");
|
|
64
|
+
return { login: exists(file) ? "found" : "missing", where: file };
|
|
65
|
+
}
|
|
66
|
+
if (engine === "claude") {
|
|
67
|
+
// An exported subscription token is a login the engine can read in `machine` mode.
|
|
68
|
+
if (env.CLAUDE_CODE_OAUTH_TOKEN?.trim())
|
|
69
|
+
return { login: "found", where: "CLAUDE_CODE_OAUTH_TOKEN" };
|
|
70
|
+
const dir = env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude");
|
|
71
|
+
const file = join(dir, ".credentials.json");
|
|
72
|
+
if (exists(file))
|
|
73
|
+
return { login: "found", where: file };
|
|
74
|
+
if (platform === "darwin") {
|
|
75
|
+
// Claude Code keeps its macOS login in the keychain. 0 = the item exists, 44 = it does
|
|
76
|
+
// not; anything else (a locked keychain, no `security`) is not an answer.
|
|
77
|
+
const status = (deps.keychainItemStatus ?? defaultKeychainItemStatus)("Claude Code-credentials");
|
|
78
|
+
if (status === 0)
|
|
79
|
+
return { login: "found", where: "keychain" };
|
|
80
|
+
if (status === 44)
|
|
81
|
+
return { login: "missing", where: `keychain / ${file}` };
|
|
82
|
+
return { login: "unknown" };
|
|
83
|
+
}
|
|
84
|
+
return { login: "missing", where: file };
|
|
85
|
+
}
|
|
86
|
+
if (engine === "gemini") {
|
|
87
|
+
// Google sign-in lands here; Gemini also accepts other auth (gcloud, Vertex), so absence is
|
|
88
|
+
// not proof of "signed out".
|
|
89
|
+
const file = join(home, ".gemini", "oauth_creds.json");
|
|
90
|
+
return exists(file) ? { login: "found", where: file } : { login: "unknown" };
|
|
91
|
+
}
|
|
92
|
+
return { login: "unknown" };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The whole answer. `credentialInjected` is the cloud saying it will hand this spawn a credential
|
|
96
|
+
* of its own (a platform-stored subscription token), in which case the machine's login is not what
|
|
97
|
+
* the engine reads and is not checked.
|
|
98
|
+
*/
|
|
99
|
+
export function checkEngine(input, deps = {}) {
|
|
100
|
+
const engine = (["claude", "gemini", "codex", "grok", "generic"].includes(input.clientType ?? "") ? input.clientType : "claude");
|
|
101
|
+
const parsed = parseCommand(input.command);
|
|
102
|
+
const bin = parsed.bin || parseCommand(handlerFor(engine).cliCommand).bin || engine;
|
|
103
|
+
const binaryPath = resolveBinary(bin, deps);
|
|
104
|
+
const login = input.credentialInjected ? { login: "found", where: "platform" } : detectEngineLogin(engine, deps);
|
|
105
|
+
return {
|
|
106
|
+
checked: true,
|
|
107
|
+
engine,
|
|
108
|
+
bin,
|
|
109
|
+
binaryFound: binaryPath !== null,
|
|
110
|
+
...(binaryPath ? { binaryPath } : {}),
|
|
111
|
+
login: login.login,
|
|
112
|
+
...(login.where ? { loginWhere: login.where } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -19,6 +19,28 @@
|
|
|
19
19
|
*/
|
|
20
20
|
/** Longest engine line carried as {@link EngineTurnReport.detail}. The pane holds the rest. */
|
|
21
21
|
export const MAX_TURN_DETAIL = 240;
|
|
22
|
+
/**
|
|
23
|
+
* How much of a turn's own output rides on its report as {@link EngineTurnReport.tail} (#889).
|
|
24
|
+
*
|
|
25
|
+
* The last LINES, because the actionable part of a failure is at the end — Codex retried a 401 five
|
|
26
|
+
* times and then said so in `turn.failed`. Bounded three ways (lines, per-line, total) because the
|
|
27
|
+
* report is re-sent on every snapshot and a runaway line must not grow it.
|
|
28
|
+
*/
|
|
29
|
+
export const MAX_TURN_TAIL_LINES = 60;
|
|
30
|
+
export const MAX_TURN_TAIL_LINE = 400;
|
|
31
|
+
export const MAX_TURN_TAIL_CHARS = 8000;
|
|
32
|
+
/** Append one line of the turn's own output to its tail, keeping the newest within the bounds above. */
|
|
33
|
+
export function appendTurnTail(tail, line) {
|
|
34
|
+
const text = line.trim();
|
|
35
|
+
if (!text)
|
|
36
|
+
return;
|
|
37
|
+
tail.push(text.length > MAX_TURN_TAIL_LINE ? `${text.slice(0, MAX_TURN_TAIL_LINE)}…` : text);
|
|
38
|
+
while (tail.length > MAX_TURN_TAIL_LINES)
|
|
39
|
+
tail.shift();
|
|
40
|
+
let total = tail.reduce((n, l) => n + l.length + 1, 0);
|
|
41
|
+
while (total > MAX_TURN_TAIL_CHARS && tail.length > 1)
|
|
42
|
+
total -= tail.shift().length + 1;
|
|
43
|
+
}
|
|
22
44
|
/**
|
|
23
45
|
* A one-shot turn's process has exited — say what that means, and nothing more.
|
|
24
46
|
*
|
|
@@ -26,14 +48,16 @@ export const MAX_TURN_DETAIL = 240;
|
|
|
26
48
|
* 15-minute wedge ceiling and `interrupt()` both land here, and counting either as an engine
|
|
27
49
|
* failure would let three slow builds read as a broken CLI.
|
|
28
50
|
*/
|
|
29
|
-
export function turnReportFromExit(code, signal, lastLine = "", now = Date.now()) {
|
|
30
|
-
const detail = lastLine.trim().slice(0, MAX_TURN_DETAIL);
|
|
51
|
+
export function turnReportFromExit(code, signal, lastLine = "", now = Date.now(), tail = []) {
|
|
52
|
+
const detail = (lastLine.trim() || tail.at(-1) || "").slice(0, MAX_TURN_DETAIL);
|
|
53
|
+
const verdict = signal !== null ? "killed" : code === 0 ? "ok" : "failed";
|
|
31
54
|
return {
|
|
32
|
-
verdict
|
|
55
|
+
verdict,
|
|
33
56
|
exitCode: code,
|
|
34
57
|
signal,
|
|
35
58
|
at: now,
|
|
36
59
|
...(detail ? { detail } : {}),
|
|
60
|
+
...(verdict === "failed" ? { tail: [...tail] } : {}),
|
|
37
61
|
};
|
|
38
62
|
}
|
|
39
63
|
/**
|
|
@@ -43,7 +67,7 @@ export function turnReportFromExit(code, signal, lastLine = "", now = Date.now()
|
|
|
43
67
|
* the shape of gap that makes a platform-wide claim ("we notice a failed turn") false in the one
|
|
44
68
|
* case that runs most.
|
|
45
69
|
*/
|
|
46
|
-
export function turnReportFromResult(isError, detail = "", now = Date.now()) {
|
|
70
|
+
export function turnReportFromResult(isError, detail = "", now = Date.now(), tail = []) {
|
|
47
71
|
const text = detail.trim().slice(0, MAX_TURN_DETAIL);
|
|
48
72
|
return {
|
|
49
73
|
verdict: isError ? "failed" : "ok",
|
|
@@ -51,5 +75,6 @@ export function turnReportFromResult(isError, detail = "", now = Date.now()) {
|
|
|
51
75
|
signal: null,
|
|
52
76
|
at: now,
|
|
53
77
|
...(text ? { detail: text } : {}),
|
|
78
|
+
...(isError ? { tail: [...tail] } : {}),
|
|
54
79
|
};
|
|
55
80
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { classifyCommand, commandFromToolInput, fillTargetFromResult, toolCallOk, toolResultMark } from "./engine-acts.js";
|
|
3
3
|
import { parseEngineUsage } from "./engine-usage.js";
|
|
4
|
-
import { turnReportFromExit, turnReportFromResult } from "./engine-turn.js";
|
|
4
|
+
import { appendTurnTail, turnReportFromExit, turnReportFromResult } from "./engine-turn.js";
|
|
5
5
|
import { renderToolResult, shortInput, stripAnsi } from "./transcript-lines.js";
|
|
6
6
|
import { authoredTurn, authorTag } from "./turn-author.js";
|
|
7
7
|
import { engineSpawnEnv, mergeEnv } from "./engine-env.js";
|
|
@@ -98,6 +98,8 @@ export class HeadlessSession {
|
|
|
98
98
|
* carry a previous turn's line.
|
|
99
99
|
*/
|
|
100
100
|
turnLastLine = "";
|
|
101
|
+
/** This turn's own output, newest last and bounded — reported on a failed turn (#889). */
|
|
102
|
+
turnTail = [];
|
|
101
103
|
/** True once this session has retried an old Codex CLI without its unsupported `--json` flag. */
|
|
102
104
|
fellBackToRaw = false;
|
|
103
105
|
/** Structured events observed in the current one-shot process. */
|
|
@@ -408,8 +410,11 @@ export class HeadlessSession {
|
|
|
408
410
|
this.lastOutputAt = Date.now();
|
|
409
411
|
this.sawOutputSinceInput = true;
|
|
410
412
|
const text = d.toString("utf8").trim();
|
|
411
|
-
if (text)
|
|
413
|
+
if (text) {
|
|
412
414
|
this.push(`[${this.binName}] ${stripAnsi(text)}`);
|
|
415
|
+
for (const l of stripAnsi(text).split("\n"))
|
|
416
|
+
appendTurnTail(this.turnTail, l);
|
|
417
|
+
}
|
|
413
418
|
});
|
|
414
419
|
proc.on("exit", (code) => {
|
|
415
420
|
// A stop() + re-start() on the SAME session object can leave this old process's
|
|
@@ -471,6 +476,7 @@ export class HeadlessSession {
|
|
|
471
476
|
this.lastOutputAt = now;
|
|
472
477
|
this.turnStartedAt = now;
|
|
473
478
|
this.sawOutputSinceInput = false; // arm the persistent-raw idle heuristic for THIS turn
|
|
479
|
+
this.turnTail = []; // a one-shot turn re-arms it at its spawn too (a --json fallback re-runs the turn)
|
|
474
480
|
try {
|
|
475
481
|
// Brief first, instruction second, and never the other way round: it is background for
|
|
476
482
|
// the request, and an engine that reads the request last acts on the request.
|
|
@@ -510,6 +516,7 @@ export class HeadlessSession {
|
|
|
510
516
|
// Arm the per-turn line capture BEFORE the spawn, so a report can only ever carry a line
|
|
511
517
|
// this turn produced (#545).
|
|
512
518
|
this.turnLastLine = "";
|
|
519
|
+
this.turnTail = [];
|
|
513
520
|
this.sawStructuredEvent = false;
|
|
514
521
|
this.structuredOutputRejected = false;
|
|
515
522
|
const resumedCodexThreadId = this.codexResumeThreadId;
|
|
@@ -546,6 +553,7 @@ export class HeadlessSession {
|
|
|
546
553
|
if (this.proc !== proc)
|
|
547
554
|
return; // stale: a newer turn owns the session now
|
|
548
555
|
this.push(`[${this.config.clientType}] failed to start: ${err.message}`);
|
|
556
|
+
appendTurnTail(this.turnTail, `failed to start: ${err.message}`);
|
|
549
557
|
this.run = "idle";
|
|
550
558
|
this.proc = null;
|
|
551
559
|
// The command itself is unrunnable — report the session dead so the loop stops with a
|
|
@@ -610,7 +618,7 @@ export class HeadlessSession {
|
|
|
610
618
|
//
|
|
611
619
|
// A signal means WE ended it (the wedge ceiling, an interrupt), which is the `killed`
|
|
612
620
|
// verdict: evidence about this platform's timers, not about the engine's health.
|
|
613
|
-
this.turnReport = turnReportFromExit(code, signal, this.turnLastLine);
|
|
621
|
+
this.turnReport = turnReportFromExit(code, signal, this.turnLastLine, Date.now(), this.turnTail);
|
|
614
622
|
this.run = "idle";
|
|
615
623
|
this.proc = null;
|
|
616
624
|
});
|
|
@@ -664,8 +672,11 @@ export class HeadlessSession {
|
|
|
664
672
|
if (this.mode === "stream-json") {
|
|
665
673
|
if (this.handle(line))
|
|
666
674
|
this.sawStructuredEvent = true;
|
|
667
|
-
else
|
|
668
|
-
this.
|
|
675
|
+
else {
|
|
676
|
+
if (this.adapter.rejectsStructuredOutput?.(line))
|
|
677
|
+
this.structuredOutputRejected = true;
|
|
678
|
+
this.pushUnframed(line);
|
|
679
|
+
}
|
|
669
680
|
}
|
|
670
681
|
else
|
|
671
682
|
this.pushRaw(line); // raw engine — the line IS the terminal output
|
|
@@ -687,11 +698,34 @@ export class HeadlessSession {
|
|
|
687
698
|
get codexResumeThreadId() {
|
|
688
699
|
return this.config.clientType === "codex" && this.mode === "stream-json" && isCodexThreadId(this.codexThreadId) ? this.codexThreadId : null;
|
|
689
700
|
}
|
|
701
|
+
/**
|
|
702
|
+
* A line a STRUCTURED engine printed outside its protocol — not JSON at all (#889).
|
|
703
|
+
*
|
|
704
|
+
* A one-shot turn's stderr shares this stream, and it is where the diagnosis lives: Codex logged
|
|
705
|
+
* `ERROR codex_api…: 401 Unauthorized` there on every retry. It was dropped, which is how a turn
|
|
706
|
+
* that failed for a stated reason reached the pane and the report as no output at all. JSON this
|
|
707
|
+
* build does not recognise is still skipped — that is protocol noise, not the engine talking.
|
|
708
|
+
*/
|
|
709
|
+
pushUnframed(line) {
|
|
710
|
+
try {
|
|
711
|
+
JSON.parse(line);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
/* not JSON — the engine's own words */
|
|
716
|
+
}
|
|
717
|
+
const clean = stripAnsi(line).trim();
|
|
718
|
+
if (!clean)
|
|
719
|
+
return;
|
|
720
|
+
this.push(`[${this.binName}] ${clean}`);
|
|
721
|
+
appendTurnTail(this.turnTail, clean);
|
|
722
|
+
}
|
|
690
723
|
/** Raw-engine stdout: strip ANSI control codes and append to the transcript. */
|
|
691
724
|
pushRaw(line) {
|
|
692
725
|
const clean = stripAnsi(line);
|
|
693
726
|
if (clean.trim()) {
|
|
694
727
|
this.push(clean);
|
|
728
|
+
appendTurnTail(this.turnTail, clean);
|
|
695
729
|
// The engine's own words, kept for the turn's report (#545) — captured on the way in,
|
|
696
730
|
// never scraped back out of the rendered pane.
|
|
697
731
|
this.turnLastLine = clean.trim();
|
|
@@ -734,15 +768,21 @@ export class HeadlessSession {
|
|
|
734
768
|
this.settleAct(ev.block);
|
|
735
769
|
break;
|
|
736
770
|
}
|
|
771
|
+
case "engine_error":
|
|
772
|
+
this.push(`[${this.binName}] error: ${ev.text}`);
|
|
773
|
+
appendTurnTail(this.turnTail, `error: ${ev.text}`);
|
|
774
|
+
break;
|
|
737
775
|
case "turn_end": {
|
|
738
776
|
const failure = ev.isError ? ev.result : "";
|
|
739
|
-
if (failure)
|
|
777
|
+
if (failure) {
|
|
740
778
|
this.push(`[error] ${failure}`);
|
|
779
|
+
appendTurnTail(this.turnTail, `[error] ${failure}`);
|
|
780
|
+
}
|
|
741
781
|
// The structured path's ANALOGUE of a non-zero exit (#545). Claude has no process
|
|
742
782
|
// per turn, so `exitCode` is honestly null and the verdict comes from the protocol's
|
|
743
783
|
// own `is_error` — the same claim, in the words the engine states it in. Without
|
|
744
784
|
// this the field would exist for three engines and silently not for the flagship.
|
|
745
|
-
this.turnReport = turnReportFromResult(ev.isError, failure);
|
|
785
|
+
this.turnReport = turnReportFromResult(ev.isError, failure, Date.now(), this.turnTail);
|
|
746
786
|
// The same event that ends the turn also reports what the turn COST (#267). It was
|
|
747
787
|
// parsed and thrown away, which is why Engine spend was absent from the ledger.
|
|
748
788
|
// An errored turn still burned tokens, so this is recorded regardless of is_error.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Separate a shell command's heredoc BODIES from the text that is executed (#873).
|
|
3
|
+
*
|
|
4
|
+
* The act record carries the whole command line, and a heredoc body is data: a commit message, an
|
|
5
|
+
* issue-comment body, a Python script editing a test file. Read as commands, their lines produced
|
|
6
|
+
* acts that never happened ("merged a pull request #3" from a string inside a Python heredoc) and
|
|
7
|
+
* repository targets nobody wrote to (#872, `mcp/apps` from a comment body).
|
|
8
|
+
*
|
|
9
|
+
* VENDORED: `workers/api/src/lib/heredoc.ts` is the original; this is a copy, because a Worker must not
|
|
10
|
+
* import the runner package. `workers/api/src/lib/heredoc.test.ts` runs the same cases through both.
|
|
11
|
+
*
|
|
12
|
+
* The rules, and the one place this is stricter than "strip from `<<` to the terminator":
|
|
13
|
+
* - `<<WORD`, `<<'WORD'`, `<<"WORD"`, `<<\WORD` and `<<-WORD` open a heredoc. `<<<` is a
|
|
14
|
+
* here-string, not a heredoc. Several operators on one line are read in order, as the shell does.
|
|
15
|
+
* - An operator only counts outside quotes, except inside a `$( … )` substitution, which is how
|
|
16
|
+
* `git commit -m "$(cat <<'EOF'` is written — a `<<` inside `"a << b"` is not one.
|
|
17
|
+
* - The body is the lines AFTER the operator's line, up to a line that is exactly the delimiter
|
|
18
|
+
* (leading tabs allowed for `<<-`). The terminator line is dropped too.
|
|
19
|
+
* - The REST OF THE OPERATOR'S LINE is executed and kept: `git commit -F - <<'EOF' && git push`
|
|
20
|
+
* pushes, and dropping it would hide a real act.
|
|
21
|
+
* - No terminator — the stored command is cut at 400 characters — makes everything after the
|
|
22
|
+
* operator's line body. A heredoc that never closed cannot have run anything after it that
|
|
23
|
+
* this text shows.
|
|
24
|
+
*/
|
|
25
|
+
/** A delimiter, read at the position just after `<<`. */
|
|
26
|
+
const DELIMITER = /(-?)[ \t]*(?:'([^'\n]*)'|"([^"\n]*)"|\\?([A-Za-z_][A-Za-z0-9_]*))/y;
|
|
27
|
+
/** The heredoc operators on one executed line, in order, respecting quotes and `$( … )`. */
|
|
28
|
+
function operatorsOn(line) {
|
|
29
|
+
const out = [];
|
|
30
|
+
// A stack of contexts: "sh" (unquoted, including inside `$( … )`), "dq" and "sq".
|
|
31
|
+
const stack = ["sh"];
|
|
32
|
+
for (let i = 0; i < line.length; i++) {
|
|
33
|
+
const ch = line[i];
|
|
34
|
+
const top = stack[stack.length - 1];
|
|
35
|
+
if (top === "sq") {
|
|
36
|
+
if (ch === "'")
|
|
37
|
+
stack.pop();
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (ch === "\\") {
|
|
41
|
+
i++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (ch === "$" && line[i + 1] === "(") {
|
|
45
|
+
stack.push("sh");
|
|
46
|
+
i++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (top === "dq") {
|
|
50
|
+
if (ch === '"')
|
|
51
|
+
stack.pop();
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (ch === "'")
|
|
55
|
+
stack.push("sq");
|
|
56
|
+
else if (ch === '"')
|
|
57
|
+
stack.push("dq");
|
|
58
|
+
else if (ch === ")" && stack.length > 1)
|
|
59
|
+
stack.pop();
|
|
60
|
+
else if (ch === "#" && (i === 0 || /\s/.test(line[i - 1])))
|
|
61
|
+
break;
|
|
62
|
+
else if (ch === "<" && line[i + 1] === "<" && line[i + 2] !== "<" && line[i - 1] !== "<") {
|
|
63
|
+
DELIMITER.lastIndex = i + 2;
|
|
64
|
+
const m = DELIMITER.exec(line);
|
|
65
|
+
const word = m ? (m[2] ?? m[3] ?? m[4]) : undefined;
|
|
66
|
+
if (m && word) {
|
|
67
|
+
out.push({ word, dash: m[1] === "-" });
|
|
68
|
+
i = DELIMITER.lastIndex - 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
export function splitHeredocs(command) {
|
|
75
|
+
const executed = [];
|
|
76
|
+
const bodies = [];
|
|
77
|
+
const elided = [];
|
|
78
|
+
const pending = [];
|
|
79
|
+
let bodyLines = 0;
|
|
80
|
+
const marker = () => `[heredoc body: ${bodyLines} line${bodyLines === 1 ? "" : "s"}]`;
|
|
81
|
+
for (const raw of String(command ?? "").split("\n")) {
|
|
82
|
+
const line = raw.replace(/\r$/, "");
|
|
83
|
+
if (pending.length) {
|
|
84
|
+
const { word, dash } = pending[0];
|
|
85
|
+
if ((dash ? line.replace(/^\t+/, "") : line) === word) {
|
|
86
|
+
pending.shift();
|
|
87
|
+
elided.push(marker(), raw);
|
|
88
|
+
bodyLines = 0;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
bodies.push(raw);
|
|
92
|
+
bodyLines++;
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
executed.push(raw);
|
|
97
|
+
elided.push(raw);
|
|
98
|
+
pending.push(...operatorsOn(line));
|
|
99
|
+
}
|
|
100
|
+
if (pending.length)
|
|
101
|
+
elided.push(marker());
|
|
102
|
+
return { executed: executed.join("\n"), bodies: bodies.join("\n"), unterminated: pending.length > 0, elided: elided.join("\n") };
|
|
103
|
+
}
|
|
@@ -2,6 +2,7 @@ import { createServer, } from "node:http";
|
|
|
2
2
|
import { URL } from "node:url";
|
|
3
3
|
import { LocalRunner, RunnerInputError } from "./runner.js";
|
|
4
4
|
import { probeGitSshIdentity } from "./coding/repo.js";
|
|
5
|
+
import { checkEngine } from "./coding/engine-check.js";
|
|
5
6
|
import { listGithubOrgs, listGithubRepos, searchGithubRepos, getGithubRepoDetail, getGithubCredentialScope } from "./coding/github-browse.js";
|
|
6
7
|
export function createRunnerServer(runner) {
|
|
7
8
|
return createServer(async (req, res) => {
|
|
@@ -383,6 +384,18 @@ async function route(runner, req, res) {
|
|
|
383
384
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
384
385
|
}
|
|
385
386
|
}
|
|
387
|
+
// Can this machine run an engine — its binary on PATH, its subscription login stored (#879)? Asked
|
|
388
|
+
// BEFORE a session is launched, so a missing engine is a named refusal rather than a dead pane.
|
|
389
|
+
// An older runner 404s this, and the cloud reads that as "unverified", never as "missing".
|
|
390
|
+
if (req.method === "POST" && path === "/coding/engine-check") {
|
|
391
|
+
const b = await readJson(req);
|
|
392
|
+
try {
|
|
393
|
+
return json(res, 200, checkEngine(b));
|
|
394
|
+
}
|
|
395
|
+
catch (e) {
|
|
396
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
386
399
|
// Background cold-start clones (#858): start (or join) one, and read it back. An older runner 404s
|
|
387
400
|
// both, and the cloud falls back to the synchronous `/coding/clone` below.
|
|
388
401
|
if (req.method === "POST" && (path === "/coding/clone-start" || path === "/coding/clone-status")) {
|