@proagentstore/cli 0.4.25 → 0.4.27

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.
@@ -42,6 +42,8 @@ export class HeadlessSession {
42
42
  sawOutputSinceInput = false;
43
43
  /** Raw-mode: when the current turn started (absolute idle backstop). */
44
44
  turnStartedAt = 0;
45
+ /** Set by stop() — the only thing that ends a one-shot session (see `alive`). */
46
+ stopped = false;
45
47
  constructor(config) {
46
48
  this.config = config;
47
49
  this.sessionName = `pags-${config.clientType}-${config.id}`;
@@ -60,13 +62,36 @@ export class HeadlessSession {
60
62
  this.cmdArgs = bin ? args : fallback.args;
61
63
  this.binName = (this.cmdBin.split("/").pop() || this.cmdBin) || "cli";
62
64
  }
63
- /** True while the agent process is running. NOTE: do NOT use `proc.killed` — Node sets
64
- * it true the instant a signal is DELIVERED, not when the process exits. interrupt()
65
- * SIGINTs to abort a turn while the process keeps running, so `killed` gave a false
66
- * "dead" → input() then spawned a SECOND process (orphaning the first). exitCode is
67
- * null while running and set on normal exit; signalCode is set when a signal actually
68
- * terminated it — together they're the real liveness signal. */
65
+ /**
66
+ * Can this session take a turn? — NOT "is a process executing right now".
67
+ *
68
+ * The distinction only appeared with one-shot engines. A persistent Claude session has a
69
+ * process alive between turns, so the two questions had the same answer and one getter served
70
+ * both. A one-shot engine has NO process between turns — that is its resting state — so the
71
+ * process-liveness answer is `false` for every question asked while the session sits idle,
72
+ * which is exactly when the Pilot asks.
73
+ *
74
+ * The cost of conflating them was total: `runCodingLoop` opens with
75
+ * `if (!snap.alive) return failed("coding session is not running")`, so every delegated goal
76
+ * on codex/grok/gemini/ollama died at iteration 0 having done nothing, reporting a dead
77
+ * session that was in fact healthy and answering interactive turns fine.
78
+ *
79
+ * So: a one-shot session is alive until `stop()`. Whether a turn is currently executing is
80
+ * `runState`, which is the question the loop actually has a use for.
81
+ *
82
+ * NOTE for the persistent path: do NOT use `proc.killed` — Node sets it true the instant a
83
+ * signal is DELIVERED, not when the process exits. interrupt() SIGINTs to abort a turn while
84
+ * the process keeps running, so `killed` gave a false "dead" → input() then spawned a SECOND
85
+ * process (orphaning the first). exitCode is null while running and set on normal exit;
86
+ * signalCode is set when a signal actually terminated it — together they're the real signal.
87
+ */
69
88
  get alive() {
89
+ if (this.oneShot)
90
+ return !this.stopped;
91
+ return this.procAlive;
92
+ }
93
+ /** Is a process running THIS instant? The persistent engine's liveness, and the spawn guard. */
94
+ get procAlive() {
70
95
  return this.proc !== null && this.proc.exitCode === null && this.proc.signalCode === null;
71
96
  }
72
97
  /** Idle = ready for the next instruction. */
@@ -125,13 +150,16 @@ export class HeadlessSession {
125
150
  return this.mode === "raw";
126
151
  }
127
152
  start() {
153
+ // Starting always un-stops: `stop()` is what ends a one-shot session, so a (re)start
154
+ // after one has to bring it back or the session is permanently unusable.
155
+ this.stopped = false;
128
156
  // A one-shot engine has nothing to start until there is a turn to run; starting it here
129
157
  // is what produced the instant "exited with code 1".
130
158
  if (this.oneShot) {
131
159
  this.run = "idle";
132
160
  return;
133
161
  }
134
- if (this.alive)
162
+ if (this.procAlive)
135
163
  return;
136
164
  // stream-json: we own the structural flags + --resume; merge the user's extras
137
165
  // (e.g. --model) without letting them clobber or orphan-value our flags. raw:
@@ -227,10 +255,24 @@ export class HeadlessSession {
227
255
  env: mergeEnv(process.env, this.config.env),
228
256
  stdio: ["ignore", "pipe", "pipe"],
229
257
  });
258
+ // A turn already running is aborted before its replacement starts. `input()` never killed
259
+ // the previous process, and the raw idle heuristic declares idle after a >1.5s output
260
+ // pause — so a long build could be judged idle, the brain sends turn 2, and TWO engine
261
+ // processes edit the same repo at once.
262
+ if (this.procAlive) {
263
+ try {
264
+ this.proc?.kill();
265
+ }
266
+ catch {
267
+ /* already gone */
268
+ }
269
+ }
230
270
  this.proc = proc;
231
271
  // Without an 'error' listener a spawn failure (binary not on PATH) is an uncaught
232
272
  // exception that takes the whole runner down, not just this session.
233
273
  proc.on("error", (err) => {
274
+ if (this.proc !== proc)
275
+ return; // stale: a newer turn owns the session now
234
276
  this.push(`[${this.config.clientType}] failed to start: ${err.message}`);
235
277
  this.run = "idle";
236
278
  this.proc = null;
@@ -243,13 +285,29 @@ export class HeadlessSession {
243
285
  // looked like an idle session for a whole afternoon.
244
286
  if (code)
245
287
  this.push(`[${this.config.clientType} exited with code ${code}]`);
288
+ // STALENESS GUARD, matching the persistent path's. Without it, an older turn's
289
+ // `close` clobbered the newer one: `run = "idle"` while process B was still working
290
+ // (so the brain acted on a half-done turn and could double-send), and `proc = null`
291
+ // so stop()/interrupt()/end() could no longer kill B — ending the session left a
292
+ // codex process still editing the repo, invisible to `alive`, diagnostics and
293
+ // kill-tmux.
294
+ if (this.proc !== proc)
295
+ return;
246
296
  this.run = "idle";
247
297
  this.proc = null;
248
298
  });
249
299
  }
250
- /** No TTY in headless mode; control is via messages. Kept for interface parity. */
251
- key(_keys) {
252
- /* intentionally a no-op — there are no raw keystrokes without a terminal */
300
+ /**
301
+ * No TTY in headless mode; control is via messages. Kept for interface parity — the human
302
+ * takeover path can still route a keypress here.
303
+ *
304
+ * RECORDED rather than silently dropped. As a pure no-op it was indistinguishable from
305
+ * success: `act` returned an ordinary snapshot with an unchanged pane, so the caller could not
306
+ * tell "sent, nothing happened" from "never sent". The transcript is what the brain and the
307
+ * console both read, so the truth belongs there.
308
+ */
309
+ key(keys) {
310
+ this.push(`[ignored keypress ${keys.slice(0, 40)} — this session has no terminal attached]`);
253
311
  }
254
312
  /** Abort the current turn (SIGINT, like Ctrl-C). The process stays usable. */
255
313
  interrupt() {
@@ -262,7 +320,7 @@ export class HeadlessSession {
262
320
  this.run = "idle";
263
321
  this.lastOutputAt = Date.now();
264
322
  }
265
- /** Tear the process down. */
323
+ /** Tear the process down. For a one-shot engine this is the ONLY thing that ends the session. */
266
324
  stop() {
267
325
  try {
268
326
  this.proc?.kill();
@@ -272,6 +330,7 @@ export class HeadlessSession {
272
330
  }
273
331
  this.proc = null;
274
332
  this.run = "idle";
333
+ this.stopped = true;
275
334
  }
276
335
  // ── internals ────────────────────────────────────────────────────────────
277
336
  onStdout(chunk) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.25",
3
+ "version": "0.4.27",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",