@proagentstore/cli 0.4.50 → 0.4.51

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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * How the LAST completed engine turn ended (#545).
3
+ *
4
+ * The exit code was already known on this side and spent entirely on prose: `runOneShot`'s close
5
+ * handler pushed `[codex exited with code 1]` into the transcript and set no field, so a production
6
+ * Codex session whose every turn exited 1 — three times, the reason printed each time — reported
7
+ * `alive: true, ready: true, runState: "idle"`, and the Pilot spent fifteen minutes and three BYOK
8
+ * decisions rediscovering it from the pane.
9
+ *
10
+ * OUTCOME AND LIVENESS ARE TWO FACTS, and this module owns only the first. Nothing here touches
11
+ * `alive`: a one-shot session has no process between turns, a failing turn does not make the
12
+ * session unable to take another, and conflating the two once killed every delegated goal on
13
+ * codex/grok/gemini at iteration 0 (see `HeadlessSession.alive`). The failure is REPORTED here;
14
+ * what to make of one is the cloud's judgement, in `workers/api/src/lib/coding-turn-outcome.ts`.
15
+ *
16
+ * Its own module, beside `engine-usage.ts` / `engine-acts.ts` / `engine-auth.ts`, for the reason
17
+ * they are: the RULE that turns a process exit into a verdict is worth testing without spawning a
18
+ * process, and it is the part someone will later be tempted to change.
19
+ */
20
+ /** Longest engine line carried as {@link EngineTurnReport.detail}. The pane holds the rest. */
21
+ export const MAX_TURN_DETAIL = 240;
22
+ /**
23
+ * A one-shot turn's process has exited — say what that means, and nothing more.
24
+ *
25
+ * A SIGNAL outranks the code because a signalled process's code is null and the kill is ours: the
26
+ * 15-minute wedge ceiling and `interrupt()` both land here, and counting either as an engine
27
+ * failure would let three slow builds read as a broken CLI.
28
+ */
29
+ export function turnReportFromExit(code, signal, lastLine = "", now = Date.now()) {
30
+ const detail = lastLine.trim().slice(0, MAX_TURN_DETAIL);
31
+ return {
32
+ verdict: signal !== null ? "killed" : code === 0 ? "ok" : "failed",
33
+ exitCode: code,
34
+ signal,
35
+ at: now,
36
+ ...(detail ? { detail } : {}),
37
+ };
38
+ }
39
+ /**
40
+ * A stream-json turn ended with a `result` event — the structured path's analogue of an exit code.
41
+ *
42
+ * Without it the field would exist for three engines and silently not for the flagship, which is
43
+ * the shape of gap that makes a platform-wide claim ("we notice a failed turn") false in the one
44
+ * case that runs most.
45
+ */
46
+ export function turnReportFromResult(isError, detail = "", now = Date.now()) {
47
+ const text = detail.trim().slice(0, MAX_TURN_DETAIL);
48
+ return {
49
+ verdict: isError ? "failed" : "ok",
50
+ exitCode: null,
51
+ signal: null,
52
+ at: now,
53
+ ...(text ? { detail: text } : {}),
54
+ };
55
+ }
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { classifyCommand, commandFromToolInput, fillTargetFromResult } from "./engine-acts.js";
3
3
  import { parseEngineUsage } from "./engine-usage.js";
4
+ import { turnReportFromExit, turnReportFromResult } from "./engine-turn.js";
4
5
  /**
5
6
  * Merge the platform's resolved engine env over the machine's, where an EMPTY value means
6
7
  * REMOVE rather than "set to empty".
@@ -88,6 +89,16 @@ export class HeadlessSession {
88
89
  turnStartedAt = 0;
89
90
  /** Set by stop() — the only thing that ends a one-shot session (see `alive`). */
90
91
  stopped = false;
92
+ /** How the last COMPLETED turn ended (#545). Null until one has. See {@link EngineTurnReport}. */
93
+ turnReport = null;
94
+ /**
95
+ * The engine's own last output line of the turn in flight (#545).
96
+ *
97
+ * Recorded as it is written, so the failure detail is the engine's sentence rather than
98
+ * something parsed back out of a rendered pane. Reset when a turn starts, so a report can never
99
+ * carry a previous turn's line.
100
+ */
101
+ turnLastLine = "";
91
102
  /** Measured engine spend not yet handed to the cloud (#267). Drained by {@link takeUsage}. */
92
103
  pendingUsage = [];
93
104
  /**
@@ -216,6 +227,15 @@ export class HeadlessSession {
216
227
  return !this.stopped && !this.spawnFailed;
217
228
  return this.procAlive;
218
229
  }
230
+ /**
231
+ * How the last completed turn ended (#545) — NOT whether the session can take another.
232
+ *
233
+ * Read by the snapshot and carried to the cloud. Null means no turn has completed on this
234
+ * session; it is never a claim that a turn went well.
235
+ */
236
+ get lastTurn() {
237
+ return this.turnReport;
238
+ }
219
239
  /** Is a process running THIS instant? The persistent engine's liveness, and the spawn guard. */
220
240
  get procAlive() {
221
241
  return this.proc !== null && this.proc.exitCode === null && this.proc.signalCode === null;
@@ -397,6 +417,9 @@ export class HeadlessSession {
397
417
  * preset decides what runs and what it costs; the platform does not need to know the engine.
398
418
  */
399
419
  runOneShot(text) {
420
+ // Arm the per-turn line capture BEFORE the spawn, so a report can only ever carry a line
421
+ // this turn produced (#545).
422
+ this.turnLastLine = "";
400
423
  const proc = spawn(this.cmdBin, [...this.cmdArgs, text], {
401
424
  cwd: this.config.workDir,
402
425
  env: mergeEnv(process.env, this.config.env),
@@ -450,7 +473,7 @@ export class HeadlessSession {
450
473
  }
451
474
  }, maxTurnMs);
452
475
  ceiling.unref();
453
- proc.on("close", (code) => {
476
+ proc.on("close", (code, signal) => {
454
477
  clearTimeout(ceiling); // cleared before the staleness guard: the timer belongs to THIS process
455
478
  // A non-zero exit is the engine's own failure (bad flags, not signed in) and the
456
479
  // operator needs to see it — silently going idle is how "stdin is not a terminal"
@@ -465,6 +488,14 @@ export class HeadlessSession {
465
488
  // kill-tmux.
466
489
  if (this.proc !== proc)
467
490
  return;
491
+ // THE EXIT CODE STOPS BEING ONLY PROSE HERE (#545). Recorded after the staleness guard
492
+ // on purpose: a turn aborted by its successor (see the kill above) must not overwrite
493
+ // the report of the turn that replaced it — the loser's outcome is about a turn nobody
494
+ // is waiting on any more.
495
+ //
496
+ // A signal means WE ended it (the wedge ceiling, an interrupt), which is the `killed`
497
+ // verdict: evidence about this platform's timers, not about the engine's health.
498
+ this.turnReport = turnReportFromExit(code, signal, this.turnLastLine);
468
499
  this.run = "idle";
469
500
  this.proc = null;
470
501
  });
@@ -536,8 +567,12 @@ export class HeadlessSession {
536
567
  /** Raw-engine stdout: strip ANSI control codes and append to the transcript. */
537
568
  pushRaw(line) {
538
569
  const clean = stripAnsi(line);
539
- if (clean.trim())
570
+ if (clean.trim()) {
540
571
  this.push(clean);
572
+ // The engine's own words, kept for the turn's report (#545) — captured on the way in,
573
+ // never scraped back out of the rendered pane.
574
+ this.turnLastLine = clean.trim();
575
+ }
541
576
  if (this.transcript.length > 4000)
542
577
  this.transcript = this.transcript.slice(-3000);
543
578
  }
@@ -576,8 +611,14 @@ export class HeadlessSession {
576
611
  }
577
612
  break;
578
613
  case "result": {
579
- if (ev.is_error)
580
- this.push(`[error] ${ev.result ?? ev.subtype ?? "failed"}`);
614
+ const failure = ev.is_error ? String(ev.result ?? ev.subtype ?? "failed") : "";
615
+ if (failure)
616
+ this.push(`[error] ${failure}`);
617
+ // The structured path's ANALOGUE of a non-zero exit (#545). Claude has no process
618
+ // per turn, so `exitCode` is honestly null and the verdict comes from the protocol's
619
+ // own `is_error` — the same claim, in the words the engine states it in. Without
620
+ // this the field would exist for three engines and silently not for the flagship.
621
+ this.turnReport = turnReportFromResult(ev.is_error === true, failure);
581
622
  // The same event that ends the turn also reports what the turn COST (#267). It was
582
623
  // parsed and thrown away, which is why Engine spend was absent from the ledger.
583
624
  // An errored turn still burned tokens, so this is recorded regardless of is_error.
@@ -151,6 +151,10 @@ export class CodingRuntime {
151
151
  // exactly the question asked about a session that just stopped.
152
152
  authResolved: session.authResolved,
153
153
  engineRuntime: session.engineRuntime,
154
+ // Reported on EVERY capture, including one where the session is not alive: "how did the
155
+ // last turn end" is exactly the question asked about a session that just stopped, and
156
+ // the omitted-when-null shape keeps "not measured" distinguishable from a verdict.
157
+ ...(session.lastTurn ? { lastTurn: session.lastTurn } : {}),
154
158
  ...(opts.drainUsage ? { usage: session.takeUsage(), acts: session.takeActs() } : {}),
155
159
  };
156
160
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.50",
3
+ "version": "0.4.51",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",