@proagentstore/cli 0.4.67 → 0.4.69

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.
@@ -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
- const result = typeof ev.error === "string" ? ev.error : typeof ev.message === "string" ? ev.message : type === "turn.failed" ? "failed" : "";
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 : "";
@@ -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: signal !== null ? "killed" : code === 0 ? "ok" : "failed",
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 if (this.adapter.rejectsStructuredOutput?.(line))
668
- this.structuredOutputRejected = true;
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
+ import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { RunnerInputError } from "../errors.js";
5
+ /**
6
+ * The runner's half of the machine-to-machine secret handoff (#918): read a secret file so the
7
+ * cloud can encrypt it, and write one back from the cloud's decrypted copy.
8
+ *
9
+ * The value crosses the relay between the API worker and this process and goes nowhere else. Both
10
+ * functions are built so it cannot leak by accident:
11
+ *
12
+ * • no shell, no tmux, no subprocess — a pane or a process table is exactly where a secret is
13
+ * visible to everything that later reads the machine (`tmux_capture_pane`, `ps`);
14
+ * • every error names the PATH and never the bytes, because a runner error becomes the cloud's
15
+ * error string, and that string reaches the model;
16
+ * • `readSecretFile` returns the value to its caller (the relay) and `writeSecretFile` returns
17
+ * only a byte count.
18
+ */
19
+ /** Big enough for any `.env`, key file or credentials JSON; small enough that this is not a file-transfer tool. */
20
+ export const SECRET_MAX_BYTES = 64 * 1024;
21
+ /** `~` expanded; a relative path resolves against the home directory, which is where `pags up` users name things from. */
22
+ function resolvePath(raw) {
23
+ const p = String(raw ?? "").trim();
24
+ if (!p)
25
+ throw new RunnerInputError("A `path` is required.");
26
+ const expanded = p.replace(/^~(?=$|\/)/, homedir());
27
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(homedir(), expanded);
28
+ }
29
+ export function readSecretFile(input) {
30
+ const path = resolvePath(input.path);
31
+ let st;
32
+ try {
33
+ st = statSync(path);
34
+ }
35
+ catch {
36
+ throw new RunnerInputError(`No file at ${path}.`);
37
+ }
38
+ if (!st.isFile())
39
+ throw new RunnerInputError(`${path} is not a regular file.`);
40
+ if (st.size === 0)
41
+ throw new RunnerInputError(`${path} is empty — there is nothing to hand off.`);
42
+ if (st.size > SECRET_MAX_BYTES)
43
+ throw new RunnerInputError(`${path} is ${st.size} bytes; the secret handoff carries at most ${SECRET_MAX_BYTES}.`);
44
+ const buf = readFileSync(path);
45
+ const value = buf.toString("utf8");
46
+ // The store holds text. A file that does not survive a UTF-8 round trip would arrive changed,
47
+ // which for a key file is worse than refusing.
48
+ if (!Buffer.from(value, "utf8").equals(buf))
49
+ throw new RunnerInputError(`${path} is not UTF-8 text; the secret handoff carries text files only.`);
50
+ return { path, value, bytes: buf.length };
51
+ }
52
+ /** `"600"` / `"0600"` / `"640"` → a file mode, owner-readable at least, never group/world-writable or executable. */
53
+ function parseMode(raw) {
54
+ if (raw == null || raw === "")
55
+ return 0o600;
56
+ const s = String(raw).trim();
57
+ if (!/^0?[0-7]{3}$/.test(s))
58
+ throw new RunnerInputError(`mode must be octal like "600", got "${s}".`);
59
+ const mode = Number.parseInt(s, 8);
60
+ if ((mode & 0o400) === 0)
61
+ throw new RunnerInputError(`mode ${s} would make the file unreadable by its owner.`);
62
+ if ((mode & 0o022) !== 0 || (mode & 0o111) !== 0)
63
+ throw new RunnerInputError(`mode ${s} is group/world-writable or executable; a secret file must not be.`);
64
+ return mode;
65
+ }
66
+ export function writeSecretFile(input) {
67
+ const path = resolvePath(input.path);
68
+ if (typeof input.value !== "string" || !input.value)
69
+ throw new RunnerInputError("Nothing to write.");
70
+ const mode = parseMode(input.mode);
71
+ let replaced = false;
72
+ try {
73
+ const st = lstatSync(path);
74
+ if (!st.isFile())
75
+ throw new RunnerInputError(`${path} exists and is not a regular file.`);
76
+ if (input.overwrite !== true)
77
+ throw new RunnerInputError(`${path} already exists. Pass overwrite: true to replace it.`);
78
+ replaced = true;
79
+ }
80
+ catch (e) {
81
+ if (e instanceof RunnerInputError)
82
+ throw e;
83
+ // Not there yet: the normal case.
84
+ }
85
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
86
+ // Written beside the target and renamed over it, so a reader never sees half a secret, and
87
+ // created with the final mode so it is never briefly world-readable.
88
+ const tmp = join(dirname(path), `.${basename(path)}.pags-${process.pid}-${Date.now()}.tmp`);
89
+ const data = Buffer.from(input.value, "utf8");
90
+ const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode);
91
+ try {
92
+ writeSync(fd, data);
93
+ fsyncSync(fd);
94
+ }
95
+ catch (e) {
96
+ closeSync(fd);
97
+ rmSync(tmp, { force: true });
98
+ throw new Error(`Could not write ${path}: ${e.code ?? "write failed"}.`);
99
+ }
100
+ closeSync(fd);
101
+ renameSync(tmp, path);
102
+ return { path, bytes: data.length, replaced };
103
+ }
@@ -541,6 +541,18 @@ async function route(runner, req, res) {
541
541
  }
542
542
  return json(res, 200, { session, created: true, workDir });
543
543
  }
544
+ // ── secret handoff (#918) ──────────────────────────────────────────────
545
+ // Machine-to-machine secret transfer for `tmux_secure_put` / `tmux_secure_get`. The value moves
546
+ // only between this process and the cloud's encrypted store — never through a pane, a shell or
547
+ // a subprocess. See coding/secret-file.ts.
548
+ if (req.method === "POST" && path === "/secure/read") {
549
+ const { readSecretFile } = await import("./coding/secret-file.js");
550
+ return json(res, 200, readSecretFile(await readJson(req)));
551
+ }
552
+ if (req.method === "POST" && path === "/secure/write") {
553
+ const { writeSecretFile } = await import("./coding/secret-file.js");
554
+ return json(res, 200, writeSecretFile(await readJson(req)));
555
+ }
544
556
  // ── generic terminal connector ──────────────────────────────────────────
545
557
  // One local-terminal vocabulary over backend-specific adapters. tmux is fully
546
558
  // controllable; kitty needs remote control enabled; iTerm2 needs macOS Automation access.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.67",
3
+ "version": "0.4.69",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",