@proagentstore/cli 0.4.40 → 0.4.42
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.
|
@@ -1,110 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-CLI
|
|
2
|
+
* Per-CLI facts the coding runtime needs before it can spawn an engine.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* This file used to carry pane heuristics too — `isReady` / `isProcessing` / `extractResponse` /
|
|
5
|
+
* `completion`, ported from AgentCoder's `bridge/src/agents/handlers/*` — which answered
|
|
6
|
+
* "is it ready for input?" by matching strings a vendor prints in its TUI. They died with the
|
|
7
|
+
* tmux backend (commit c94642f): a session is a child process now, so Claude's turn boundary is a
|
|
8
|
+
* `result` event and a raw engine's is process exit. Nothing had called them since, and keeping
|
|
9
|
+
* them left a live-looking `pane.includes("ctrl+c to interrupt")` one vendor release away from
|
|
10
|
+
* being silently wrong — for anyone who wired it back up believing it worked.
|
|
11
|
+
*
|
|
12
|
+
* What remains is data: which binary this engine is, and which env var carries its key.
|
|
9
13
|
*/
|
|
10
|
-
/** Shared "find the user's input line, return everything after it up to the next prompt". */
|
|
11
|
-
function sliceAfterInput(captured, userInput, isPromptLine) {
|
|
12
|
-
const lines = captured.split("\n");
|
|
13
|
-
const needle = userInput.slice(0, 25);
|
|
14
|
-
let start = -1;
|
|
15
|
-
if (needle) {
|
|
16
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
17
|
-
if (lines[i].includes(needle)) {
|
|
18
|
-
start = i;
|
|
19
|
-
break;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
if (start === -1)
|
|
24
|
-
return lines.slice(-200).join("\n").trim();
|
|
25
|
-
let end = lines.length;
|
|
26
|
-
for (let i = lines.length - 1; i > start; i--) {
|
|
27
|
-
if (isPromptLine(lines[i].trim())) {
|
|
28
|
-
end = i;
|
|
29
|
-
break;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return lines.slice(start + 1, end).join("\n").trim();
|
|
33
|
-
}
|
|
34
|
-
export class ClaudeHandler {
|
|
35
|
-
clientType = "claude";
|
|
36
|
-
cliCommand = "claude --dangerously-skip-permissions";
|
|
37
|
-
envVar = "ANTHROPIC_API_KEY";
|
|
38
|
-
isProcessing(pane) {
|
|
39
|
-
return pane.includes("ctrl+c to interrupt") || /Working|Thinking|Reading|Searching|Running|Editing|Writing/.test(pane);
|
|
40
|
-
}
|
|
41
|
-
isReady(pane) {
|
|
42
|
-
if (this.isProcessing(pane))
|
|
43
|
-
return false;
|
|
44
|
-
const tail = pane.split("\n").slice(-15).join("\n");
|
|
45
|
-
return (tail.includes("bypass permissions") ||
|
|
46
|
-
tail.includes("? for shortcuts") ||
|
|
47
|
-
/❯\s*$/.test(tail) ||
|
|
48
|
-
/❯ .*↵ send/.test(tail));
|
|
49
|
-
}
|
|
50
|
-
extractResponse(captured, userInput) {
|
|
51
|
-
return sliceAfterInput(captured, userInput, (l) => l === "❯" || l.startsWith("❯ "));
|
|
52
|
-
}
|
|
53
|
-
completion() {
|
|
54
|
-
return { minWait: 1000, stableThreshold: 0, forceCompleteAfter: 5 * 60 * 1000, pollInterval: 500 };
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
export class GeminiHandler {
|
|
58
|
-
clientType = "gemini";
|
|
59
|
-
cliCommand = "gemini";
|
|
60
|
-
envVar = "GEMINI_API_KEY";
|
|
61
|
-
isProcessing(pane) {
|
|
62
|
-
return /thinking|processing|generating|working|loading/i.test(pane);
|
|
63
|
-
}
|
|
64
|
-
isReady(pane) {
|
|
65
|
-
const last = pane.split("\n").slice(-1)[0]?.trim() ?? "";
|
|
66
|
-
const hasPrompt = /[>$]\s*$/.test(last) || /^>/.test(last);
|
|
67
|
-
return hasPrompt && !this.isProcessing(pane);
|
|
68
|
-
}
|
|
69
|
-
extractResponse(captured, userInput) {
|
|
70
|
-
return sliceAfterInput(captured, userInput, (l) => /^[>$]\s*$/.test(l) || /^[>$]\s+\S/.test(l));
|
|
71
|
-
}
|
|
72
|
-
completion() {
|
|
73
|
-
return { stableThreshold: 1500, forceCompleteAfter: 5 * 60 * 1000, pollInterval: 500 };
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
/** Codex / Grok render close enough to a generic prompt; reuse the shell heuristics. */
|
|
77
|
-
export class GenericHandler {
|
|
78
|
-
clientType;
|
|
79
|
-
cliCommand;
|
|
80
|
-
envVar;
|
|
81
|
-
constructor(clientType = "generic", cliCommand = "bash", envVar = "") {
|
|
82
|
-
this.clientType = clientType;
|
|
83
|
-
this.cliCommand = cliCommand;
|
|
84
|
-
this.envVar = envVar;
|
|
85
|
-
}
|
|
86
|
-
isProcessing(pane) {
|
|
87
|
-
const last = pane.split("\n").slice(-1)[0]?.trim() ?? "";
|
|
88
|
-
return /\.\.\.$/.test(last);
|
|
89
|
-
}
|
|
90
|
-
isReady(pane) {
|
|
91
|
-
const last = pane.split("\n").slice(-1)[0]?.trim() ?? "";
|
|
92
|
-
// A shell prompt ends in $, #, >, or % (optionally followed by a cursor space).
|
|
93
|
-
return /[$#>%]\s*$/.test(last) && !this.isProcessing(pane);
|
|
94
|
-
}
|
|
95
|
-
extractResponse(captured, userInput) {
|
|
96
|
-
return sliceAfterInput(captured, userInput, (l) => /[$#>%]\s*$/.test(l));
|
|
97
|
-
}
|
|
98
|
-
completion() {
|
|
99
|
-
return { stableThreshold: 800, forceCompleteAfter: 60 * 1000, pollInterval: 300 };
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
14
|
const HANDLERS = {
|
|
103
|
-
claude:
|
|
104
|
-
gemini:
|
|
105
|
-
codex:
|
|
106
|
-
grok:
|
|
107
|
-
generic:
|
|
15
|
+
claude: { clientType: "claude", cliCommand: "claude --dangerously-skip-permissions", envVar: "ANTHROPIC_API_KEY" },
|
|
16
|
+
gemini: { clientType: "gemini", cliCommand: "gemini --approval-mode yolo --skip-trust --prompt", envVar: "GEMINI_API_KEY" },
|
|
17
|
+
codex: { clientType: "codex", cliCommand: "codex exec --sandbox danger-full-access", envVar: "OPENAI_API_KEY" },
|
|
18
|
+
grok: { clientType: "grok", cliCommand: "grok --permission-mode bypassPermissions -p", envVar: "XAI_API_KEY" },
|
|
19
|
+
generic: { clientType: "generic", cliCommand: "bash", envVar: "" },
|
|
108
20
|
};
|
|
109
21
|
export function handlerFor(clientType) {
|
|
110
22
|
return HANDLERS[clientType] ?? HANDLERS.generic;
|
|
@@ -44,6 +44,19 @@ const MAX_PENDING_USAGE = 200;
|
|
|
44
44
|
* matter most, whereas the merge that started an incident is the one you cannot afford to lose.
|
|
45
45
|
*/
|
|
46
46
|
const MAX_PENDING_ACTS = 100;
|
|
47
|
+
/**
|
|
48
|
+
* Absolute ceiling on ONE one-shot turn (#391).
|
|
49
|
+
*
|
|
50
|
+
* The old idle heuristic carried a 15-minute backstop so a wedged engine could not sit "thinking"
|
|
51
|
+
* forever. Now that exit is the only thing that ends a one-shot turn, that ceiling has to be
|
|
52
|
+
* ENFORCED rather than inferred: a rule that merely relabels a still-running process as idle is
|
|
53
|
+
* the very defect #391 is about — it hands the next turn a repo another engine is still editing.
|
|
54
|
+
*
|
|
55
|
+
* So the timer ends the turn (SIGTERM) and says so in the transcript. Removing the ceiling
|
|
56
|
+
* outright would trade an early kill for a permanent hang, which is worse: nothing else on this
|
|
57
|
+
* path can unstick a process that never exits, and the console's Restart is a human noticing.
|
|
58
|
+
*/
|
|
59
|
+
const MAX_ONE_SHOT_TURN_MS = 15 * 60 * 1000;
|
|
47
60
|
export class HeadlessSession {
|
|
48
61
|
config;
|
|
49
62
|
/**
|
|
@@ -67,11 +80,11 @@ export class HeadlessSession {
|
|
|
67
80
|
cmdBin;
|
|
68
81
|
cmdArgs;
|
|
69
82
|
binName;
|
|
70
|
-
/** Wall-clock of the last stdout/stderr byte — drives the raw
|
|
83
|
+
/** Wall-clock of the last stdout/stderr byte — drives the persistent-raw idle heuristic. */
|
|
71
84
|
lastOutputAt = 0;
|
|
72
|
-
/**
|
|
85
|
+
/** Persistent-raw: has any output arrived since the current turn's input? */
|
|
73
86
|
sawOutputSinceInput = false;
|
|
74
|
-
/**
|
|
87
|
+
/** Persistent-raw: when the current turn started (absolute idle backstop). */
|
|
75
88
|
turnStartedAt = 0;
|
|
76
89
|
/** Set by stop() — the only thing that ends a one-shot session (see `alive`). */
|
|
77
90
|
stopped = false;
|
|
@@ -198,7 +211,27 @@ export class HeadlessSession {
|
|
|
198
211
|
runState() {
|
|
199
212
|
if (!this.alive)
|
|
200
213
|
return "idle";
|
|
201
|
-
//
|
|
214
|
+
// A ONE-SHOT engine's turn IS a process, so its exit is an exact end-of-turn signal and no
|
|
215
|
+
// timer may pre-empt it (#391). Idle is set by the `close` handler; here the only question
|
|
216
|
+
// is whether that process is still running.
|
|
217
|
+
//
|
|
218
|
+
// This used to fall through to the timer rules below, which were written for a PERSISTENT
|
|
219
|
+
// interactive CLI — one with no other signal to read. Against a one-shot engine the 1.5s
|
|
220
|
+
// quiet rule could only ever fire EARLY: any pause inside a turn (a test suite, an install,
|
|
221
|
+
// a slow network fetch) read as "finished", the Pilot sent turn 2, and `runOneShot` killed
|
|
222
|
+
// turn 1 to keep two engines off one repo. The heuristic that existed to prevent a
|
|
223
|
+
// premature finish was causing one, and destroying the work in flight to do it.
|
|
224
|
+
//
|
|
225
|
+
// The ceiling that stops a wedged process is armed in `runOneShot` — it ENDS the turn
|
|
226
|
+
// rather than relabelling a live one as idle, which is the same mistake in slower form.
|
|
227
|
+
if (this.oneShot)
|
|
228
|
+
return this.procAlive ? "thinking" : "idle";
|
|
229
|
+
// Below: a PERSISTENT non-Claude engine — alive between turns, so exit says nothing about
|
|
230
|
+
// a turn and idle must be inferred. None ships today; every raw engine is one-shot. The
|
|
231
|
+
// gate is `!oneShot` rather than `mode === "raw"` because the latter now means the
|
|
232
|
+
// OPPOSITE of the condition these rules were written for.
|
|
233
|
+
//
|
|
234
|
+
// Three rules, in order:
|
|
202
235
|
// 1. produced output, then went quiet for 1.5s → settled (the common case).
|
|
203
236
|
// 2. NEVER produced output but 8s elapsed → a silent turn (just a prompt) is done.
|
|
204
237
|
// 3. absolute 15-min backstop → never wedge "thinking" forever (e.g. a heartbeat
|
|
@@ -316,7 +349,7 @@ export class HeadlessSession {
|
|
|
316
349
|
const now = Date.now();
|
|
317
350
|
this.lastOutputAt = now;
|
|
318
351
|
this.turnStartedAt = now;
|
|
319
|
-
this.sawOutputSinceInput = false; // arm the raw idle heuristic for THIS turn
|
|
352
|
+
this.sawOutputSinceInput = false; // arm the persistent-raw idle heuristic for THIS turn
|
|
320
353
|
try {
|
|
321
354
|
if (this.mode === "stream-json") {
|
|
322
355
|
const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
|
|
@@ -353,11 +386,17 @@ export class HeadlessSession {
|
|
|
353
386
|
env: mergeEnv(process.env, this.config.env),
|
|
354
387
|
stdio: ["ignore", "pipe", "pipe"],
|
|
355
388
|
});
|
|
356
|
-
// A turn already running is aborted before its replacement starts
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
389
|
+
// A turn already running is aborted before its replacement starts: `input()` accepts a turn
|
|
390
|
+
// at any time — a human typing in the console, a takeover, a Loop that stopped waiting —
|
|
391
|
+
// so without this TWO engine processes edit the same repo at once. Still needed after #391
|
|
392
|
+
// made exit authoritative: that stops the Pilot from being TOLD a running turn is over, it
|
|
393
|
+
// does not stop anyone from sending anyway.
|
|
394
|
+
//
|
|
395
|
+
// It SAYS what it destroyed, for the same reason the non-zero exit code goes into the
|
|
396
|
+
// transcript below: a turn's work vanishing with no line in the record is how the Pilot
|
|
397
|
+
// ends up reasoning about a turn that never finished, with nothing to explain the gap.
|
|
360
398
|
if (this.procAlive) {
|
|
399
|
+
this.push(`[${this.config.clientType} turn aborted — a new instruction arrived while the previous one was still running]`);
|
|
361
400
|
try {
|
|
362
401
|
this.proc?.kill();
|
|
363
402
|
}
|
|
@@ -380,7 +419,23 @@ export class HeadlessSession {
|
|
|
380
419
|
});
|
|
381
420
|
proc.stdout?.on("data", (d) => this.onStdout(d.toString()));
|
|
382
421
|
proc.stderr?.on("data", (d) => this.onStdout(d.toString()));
|
|
422
|
+
// The enforced ceiling (#391). `unref` so a pending timer can never keep the runner's
|
|
423
|
+
// event loop alive past its own shutdown.
|
|
424
|
+
const maxTurnMs = this.config.maxTurnMs ?? MAX_ONE_SHOT_TURN_MS;
|
|
425
|
+
const ceiling = setTimeout(() => {
|
|
426
|
+
if (this.proc !== proc)
|
|
427
|
+
return; // a newer turn owns the session; this one is already gone
|
|
428
|
+
this.push(`[${this.config.clientType} turn ended after ${Math.round(maxTurnMs / 60000)}m — the engine never exited, so the session was unwedged]`);
|
|
429
|
+
try {
|
|
430
|
+
proc.kill();
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
/* already gone */
|
|
434
|
+
}
|
|
435
|
+
}, maxTurnMs);
|
|
436
|
+
ceiling.unref();
|
|
383
437
|
proc.on("close", (code) => {
|
|
438
|
+
clearTimeout(ceiling); // cleared before the staleness guard: the timer belongs to THIS process
|
|
384
439
|
// A non-zero exit is the engine's own failure (bad flags, not signed in) and the
|
|
385
440
|
// operator needs to see it — silently going idle is how "stdin is not a terminal"
|
|
386
441
|
// looked like an idle session for a whole afternoon.
|
|
@@ -1,15 +1,49 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* clone`. The coding engine stopped using tmux when it moved to the structured stream-json
|
|
9
|
-
* interface, so anyone cleaning up that module found its two most load-bearing functions
|
|
10
|
-
* inside it (#247). Split out so the tmux module is only tmux, and only the terminal-operator
|
|
11
|
-
* agents depend on it.
|
|
5
|
+
* Inspect a workdir: does it exist, does it hold anything, is it a checkout. Never throws —
|
|
6
|
+
* every failure is a `false`, because this function exists to REPORT a broken path and one that
|
|
7
|
+
* threw would be reported as a broken runner instead.
|
|
12
8
|
*/
|
|
9
|
+
export function checkWorkdir(dir) {
|
|
10
|
+
const absent = { checked: true, path: dir, exists: false, isDirectory: false, entryCount: 0, insideWorkTree: false, gitChecked: true };
|
|
11
|
+
let isDirectory = false;
|
|
12
|
+
try {
|
|
13
|
+
isDirectory = statSync(dir).isDirectory();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { ...absent };
|
|
17
|
+
}
|
|
18
|
+
if (!isDirectory)
|
|
19
|
+
return { ...absent, exists: true };
|
|
20
|
+
let entryCount = 0;
|
|
21
|
+
try {
|
|
22
|
+
entryCount = readdirSync(dir).length;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Unreadable (permissions) — reported as empty rather than as a crash; the cloud's
|
|
26
|
+
// message for "empty" tells the owner to look at the path either way.
|
|
27
|
+
}
|
|
28
|
+
let insideWorkTree = false;
|
|
29
|
+
let gitChecked = true;
|
|
30
|
+
try {
|
|
31
|
+
const out = execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
32
|
+
cwd: dir,
|
|
33
|
+
encoding: "utf-8",
|
|
34
|
+
timeout: 10_000,
|
|
35
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
36
|
+
});
|
|
37
|
+
insideWorkTree = out.trim() === "true";
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
// Exit 128 ("not a git repository") is an ANSWER. ENOENT is git missing from PATH, which
|
|
41
|
+
// is not — and reporting it as "not a checkout" would condemn every repo on that machine.
|
|
42
|
+
if (e?.code === "ENOENT")
|
|
43
|
+
gitChecked = false;
|
|
44
|
+
}
|
|
45
|
+
return { checked: true, path: dir, exists: true, isDirectory, entryCount, insideWorkTree, gitChecked };
|
|
46
|
+
}
|
|
13
47
|
/**
|
|
14
48
|
* A safe, collision-resistant label derived from an arbitrary string.
|
|
15
49
|
*
|
|
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { defaultStatePath, HeadlessSession } from "./headless.js";
|
|
4
4
|
import { InspectError, readGitRemoteOrigin, readRepoFile, repoTree, runRepoGit } from "./inspect.js";
|
|
5
|
-
import { ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
5
|
+
import { checkWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
6
6
|
/** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
|
|
7
7
|
const MAX_PANE = 64 * 1024;
|
|
8
8
|
export class CodingRuntime {
|
|
@@ -11,8 +11,8 @@ export class CodingRuntime {
|
|
|
11
11
|
/**
|
|
12
12
|
* Active human handoffs keyed by session id. `resolved` flips when the human
|
|
13
13
|
* finishes (console "Resume" / submits a value); the brain workflow polls
|
|
14
|
-
* {@link takeoverStatus} and continues once it does — the
|
|
15
|
-
* browser runtime's handoff-status machinery.
|
|
14
|
+
* {@link takeoverStatus} and continues once it does — the coding-session analogue of
|
|
15
|
+
* the browser runtime's handoff-status machinery.
|
|
16
16
|
*/
|
|
17
17
|
takeovers = new Map();
|
|
18
18
|
/** Base directory under which repos are cloned (one subdir per repo). */
|
|
@@ -58,6 +58,16 @@ export class CodingRuntime {
|
|
|
58
58
|
gitRemote(input) {
|
|
59
59
|
return { remote: readGitRemoteOrigin(this.resolveWorkDir(input)) };
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Is the configured workdir usable AT ALL — present, non-empty, a checkout (#405)?
|
|
63
|
+
*
|
|
64
|
+
* The only endpoint here that answers a question about the PATH rather than about its
|
|
65
|
+
* contents, and the one the cloud needs before it may call a repo `ready`. Reports the
|
|
66
|
+
* resolved path so a message can name the thing the owner actually typed, `~` and all.
|
|
67
|
+
*/
|
|
68
|
+
checkRepo(input) {
|
|
69
|
+
return checkWorkdir(this.resolveWorkDir(input));
|
|
70
|
+
}
|
|
61
71
|
static taskTypes() {
|
|
62
72
|
return ["coding.session"];
|
|
63
73
|
}
|
|
@@ -232,6 +232,18 @@ async function route(runner, req, res) {
|
|
|
232
232
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
|
+
// Does the configured workdir exist / hold files / belong to a checkout (#405)? An older
|
|
236
|
+
// runner 404s this; the cloud reads the missing `checked:true` as "unverified" and leaves
|
|
237
|
+
// the repo's status alone rather than condemning it.
|
|
238
|
+
if (req.method === "POST" && path === "/coding/repo-check") {
|
|
239
|
+
const b = await readJson(req);
|
|
240
|
+
try {
|
|
241
|
+
return json(res, 200, runner.coding.checkRepo(b));
|
|
242
|
+
}
|
|
243
|
+
catch (e) {
|
|
244
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
235
247
|
if (req.method === "POST" && path === "/coding/tree") {
|
|
236
248
|
const b = await readJson(req);
|
|
237
249
|
try {
|