@proagentstore/cli 0.4.50 → 0.4.52

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.
@@ -84,6 +84,44 @@ export function splitSegments(command) {
84
84
  .map((s) => s.trim())
85
85
  .filter(Boolean);
86
86
  }
87
+ /**
88
+ * The engine's verdict on ONE tool call — the single expression of it (#597).
89
+ *
90
+ * `is_error` is the only outcome Claude Code's stream-json protocol states for a `tool_result`: it
91
+ * sets the flag when the call failed and omits it otherwise, so `!== true` is the protocol's own
92
+ * reading rather than an inference about the result text. This is the fact `settleAct` has always
93
+ * published as a consequential act's `ok`.
94
+ *
95
+ * It is a function because two call sites now need it — the act record and the transcript line the
96
+ * cloud parses — and a verdict stated twice is a verdict that can disagree with itself.
97
+ */
98
+ export function toolCallOk(block) {
99
+ return block.is_error !== true;
100
+ }
101
+ /**
102
+ * The outcome marker on the transcript's `↳` result line — `↳✓` succeeded, `↳✗` failed (#597).
103
+ *
104
+ * The runner computed this and threw it away: `settleAct` read `is_error` while the line pushed
105
+ * into the transcript carried only the result text. The transcript is the ONLY channel the cloud
106
+ * reads for an ordinary tool call (a read, a grep, a file open leaves no `agent_events` row), so
107
+ * the per-tool-call record of #581 AC7 could state the argument and the result and never whether
108
+ * the call worked.
109
+ *
110
+ * ── Why the marker is welded to the arrow, with no space
111
+ *
112
+ * The reader (`workers/api/src/lib/engine-tool-calls.ts`) takes the outcome from the character at a
113
+ * FIXED offset after `↳`, and every runner predating this wrote a space there. So a row written by
114
+ * an old runner reads *unknown* and can never read as a pass, whatever the tool's own output
115
+ * happens to start with — which is the property AC1 asks for and the reason a marker after the
116
+ * space would not do: `toolResult()` collapses a result to one line, and vitest's begins
117
+ * `✓ src/foo.test.ts`. An old row carrying that text must not be read as a success claim.
118
+ *
119
+ * `terminal-render.ts` and `engine-auth-prompt.ts`'s `QUOTED_LINE_RE` both match `↳` unanchored on
120
+ * its right, so the pane still renders and quoted-line filtering still fires.
121
+ */
122
+ export function toolResultMark(block) {
123
+ return toolCallOk(block) ? "✓" : "✗";
124
+ }
87
125
  /** A token that is exactly the trunk, not a branch merely containing the word (`feature/main-fix`). */
88
126
  const TRUNK_TOKEN = /(?:^|\s)(?:HEAD:)?(?:refs\/heads\/)?(?:main|master)(?:\s|$)/;
89
127
  function prRef(segment) {
@@ -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
- import { classifyCommand, commandFromToolInput, fillTargetFromResult } from "./engine-acts.js";
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
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
  }
@@ -570,14 +605,20 @@ export class HeadlessSession {
570
605
  case "user": // tool results come back as a synthetic user message
571
606
  for (const block of ev.message?.content ?? []) {
572
607
  if (block.type === "tool_result") {
573
- this.push(` ↳ ${toolResult(block.content)}`); // ↳
608
+ this.push(` ↳${toolResultMark(block)} ${toolResult(block.content)}`); // ↳✓ / ↳✗ (#597)
574
609
  this.settleAct(block);
575
610
  }
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.
@@ -638,7 +679,7 @@ export class HeadlessSession {
638
679
  if (!acts)
639
680
  return;
640
681
  this.awaitingResult.delete(id);
641
- const ok = block.is_error !== true;
682
+ const ok = toolCallOk(block);
642
683
  for (const a of fillTargetFromResult(acts, block.content))
643
684
  this.publishAct({ ...a, ok });
644
685
  }
@@ -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.52",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",