@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
|
-
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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.
|
|
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
|
-
/**
|
|
251
|
-
|
|
252
|
-
|
|
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) {
|