@proagentstore/cli 0.4.40 → 0.4.41

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 behaviour for the coding runtime.
2
+ * Per-CLI facts the coding runtime needs before it can spawn an engine.
3
3
  *
4
- * Each AI coding CLI renders its TUI differently, so "is it ready for input?",
5
- * "is it still working?", and "where does its answer start/end in the pane?" are
6
- * CLI-specific. These handlers encapsulate that, ported from the AgentCoder
7
- * `bridge/src/agents/handlers/*`. A `generic` shell handler is included so the
8
- * runtime (and tests) can drive a plain shell deterministically.
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: new ClaudeHandler(),
104
- gemini: new GeminiHandler(),
105
- codex: new GenericHandler("codex", "codex", "OPENAI_API_KEY"),
106
- grok: new GenericHandler("grok", "grok", "XAI_API_KEY"),
107
- generic: new GenericHandler(),
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-mode idle heuristic. */
83
+ /** Wall-clock of the last stdout/stderr byte — drives the persistent-raw idle heuristic. */
71
84
  lastOutputAt = 0;
72
- /** Raw-mode: has any output arrived since the current turn's input? */
85
+ /** Persistent-raw: has any output arrived since the current turn's input? */
73
86
  sawOutputSinceInput = false;
74
- /** Raw-mode: when the current turn started (absolute idle backstop). */
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
- // Raw engines have no "turn over" event, so we INFER idle. Three rules, in order:
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. `input()` never killed
357
- // the previous process, and the raw idle heuristic declares idle after a >1.5s output
358
- // pause — so a long build could be judged idle, the brain sends turn 2, and TWO engine
359
- // processes edit the same repo at once.
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.
@@ -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 tmux analogue of 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). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.40",
3
+ "version": "0.4.41",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",