@proagentstore/cli 0.2.3 → 0.2.4
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.
|
@@ -15,18 +15,22 @@ export class HeadlessSession {
|
|
|
15
15
|
cmdBin;
|
|
16
16
|
cmdArgs;
|
|
17
17
|
binName;
|
|
18
|
-
/** Wall-clock of the last stdout byte — drives the raw-mode idle heuristic. */
|
|
18
|
+
/** Wall-clock of the last stdout/stderr byte — drives the raw-mode idle heuristic. */
|
|
19
19
|
lastOutputAt = 0;
|
|
20
|
+
/** Raw-mode: has any output arrived since the current turn's input? */
|
|
21
|
+
sawOutputSinceInput = false;
|
|
22
|
+
/** Raw-mode: when the current turn started (absolute idle backstop). */
|
|
23
|
+
turnStartedAt = 0;
|
|
20
24
|
constructor(config) {
|
|
21
25
|
this.config = config;
|
|
22
26
|
this.sessionName = `pags-${config.clientType}-${config.id}`;
|
|
23
27
|
this.claudeSessionId = readState(config.statePath, config.id);
|
|
24
28
|
// Claude is the structured engine; everything else is a raw CLI.
|
|
25
29
|
this.mode = config.clientType === "claude" ? "stream-json" : "raw";
|
|
26
|
-
const
|
|
27
|
-
// A test/override bin wins
|
|
28
|
-
this.cmdBin = config.bin ??
|
|
29
|
-
this.cmdArgs =
|
|
30
|
+
const { bin, args } = parseCommand(config.command);
|
|
31
|
+
// A test/override bin wins for the binary only; the command's args are kept.
|
|
32
|
+
this.cmdBin = config.bin ?? (bin || "claude");
|
|
33
|
+
this.cmdArgs = args;
|
|
30
34
|
this.binName = (this.cmdBin.split("/").pop() || this.cmdBin) || "cli";
|
|
31
35
|
}
|
|
32
36
|
/** True while the agent process is running. */
|
|
@@ -40,10 +44,23 @@ export class HeadlessSession {
|
|
|
40
44
|
runState() {
|
|
41
45
|
if (!this.alive)
|
|
42
46
|
return "idle";
|
|
43
|
-
// Raw engines have no "turn over" event, so
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
// Raw engines have no "turn over" event, so we INFER idle. Three rules, in order:
|
|
48
|
+
// 1. produced output, then went quiet for 1.5s → settled (the common case).
|
|
49
|
+
// 2. NEVER produced output but 8s elapsed → a silent turn (just a prompt) is done.
|
|
50
|
+
// 3. absolute 15-min backstop → never wedge "thinking" forever (e.g. a heartbeat
|
|
51
|
+
// that keeps refreshing lastOutputAt). The user can also Restart from diagnostics.
|
|
52
|
+
// Rule 1 deliberately waits for first output so a slow first token can't flip us
|
|
53
|
+
// to idle mid-turn (which would make the brain proceed against a half-done turn).
|
|
54
|
+
if (this.mode === "raw" && this.run === "thinking") {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const quiet = now - this.lastOutputAt;
|
|
57
|
+
const elapsed = now - this.turnStartedAt;
|
|
58
|
+
if (this.sawOutputSinceInput && quiet > 1500)
|
|
59
|
+
this.run = "idle";
|
|
60
|
+
else if (!this.sawOutputSinceInput && elapsed > 8000)
|
|
61
|
+
this.run = "idle";
|
|
62
|
+
else if (elapsed > 15 * 60 * 1000)
|
|
63
|
+
this.run = "idle";
|
|
47
64
|
}
|
|
48
65
|
return this.run === "thinking" ? "thinking" : "idle";
|
|
49
66
|
}
|
|
@@ -55,24 +72,10 @@ export class HeadlessSession {
|
|
|
55
72
|
start() {
|
|
56
73
|
if (this.alive)
|
|
57
74
|
return;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"];
|
|
63
|
-
for (const a of this.cmdArgs)
|
|
64
|
-
if (!args.includes(a))
|
|
65
|
-
args.push(a);
|
|
66
|
-
if (!args.includes("--dangerously-skip-permissions"))
|
|
67
|
-
args.push("--dangerously-skip-permissions");
|
|
68
|
-
// Continue the prior conversation after a runner restart (state in ~/.claude).
|
|
69
|
-
if (this.claudeSessionId)
|
|
70
|
-
args.push("--resume", this.claudeSessionId);
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
// Any other engine: run exactly what the user configured; capture stdout.
|
|
74
|
-
args = [...this.cmdArgs];
|
|
75
|
-
}
|
|
75
|
+
// stream-json: we own the structural flags + --resume; merge the user's extras
|
|
76
|
+
// (e.g. --model) without letting them clobber or orphan-value our flags. raw:
|
|
77
|
+
// run exactly what the user configured and capture stdout.
|
|
78
|
+
const args = this.mode === "stream-json" ? buildClaudeArgs(this.cmdArgs, this.claudeSessionId) : [...this.cmdArgs];
|
|
76
79
|
this.proc = spawn(this.cmdBin, args, {
|
|
77
80
|
cwd: this.config.workDir,
|
|
78
81
|
env: { ...process.env, ...this.config.env },
|
|
@@ -92,6 +95,7 @@ export class HeadlessSession {
|
|
|
92
95
|
this.proc.stdout?.on("data", (d) => this.onStdout(d.toString("utf8")));
|
|
93
96
|
this.proc.stderr?.on("data", (d) => {
|
|
94
97
|
this.lastOutputAt = Date.now();
|
|
98
|
+
this.sawOutputSinceInput = true;
|
|
95
99
|
const text = d.toString("utf8").trim();
|
|
96
100
|
if (text)
|
|
97
101
|
this.push(`[${this.binName}] ${stripAnsi(text)}`);
|
|
@@ -114,7 +118,10 @@ export class HeadlessSession {
|
|
|
114
118
|
this.start();
|
|
115
119
|
this.push(`\n❯ [${stamp()}] ${text}`); // ❯ — your turn, timestamped
|
|
116
120
|
this.run = "thinking";
|
|
117
|
-
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
this.lastOutputAt = now;
|
|
123
|
+
this.turnStartedAt = now;
|
|
124
|
+
this.sawOutputSinceInput = false; // arm the raw idle heuristic for THIS turn
|
|
118
125
|
try {
|
|
119
126
|
if (this.mode === "stream-json") {
|
|
120
127
|
const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
|
|
@@ -157,6 +164,7 @@ export class HeadlessSession {
|
|
|
157
164
|
// ── internals ────────────────────────────────────────────────────────────
|
|
158
165
|
onStdout(chunk) {
|
|
159
166
|
this.lastOutputAt = Date.now();
|
|
167
|
+
this.sawOutputSinceInput = true; // a slow first token must NOT count as idle
|
|
160
168
|
this.buf += chunk;
|
|
161
169
|
let nl;
|
|
162
170
|
// biome-ignore lint/suspicious/noAssignInExpressions: standard line-buffer drain
|
|
@@ -170,6 +178,13 @@ export class HeadlessSession {
|
|
|
170
178
|
else
|
|
171
179
|
this.pushRaw(line); // raw engine — the line IS the terminal output
|
|
172
180
|
}
|
|
181
|
+
// A TUI/raw engine may render without newlines; don't let `buf` grow unbounded
|
|
182
|
+
// (and surface the partial output so the pane isn't blank). 16KB is generous.
|
|
183
|
+
if (this.buf.length > 16 * 1024) {
|
|
184
|
+
if (this.mode === "raw")
|
|
185
|
+
this.pushRaw(this.buf);
|
|
186
|
+
this.buf = "";
|
|
187
|
+
}
|
|
173
188
|
}
|
|
174
189
|
/** Raw-engine stdout: strip ANSI control codes and append to the transcript. */
|
|
175
190
|
pushRaw(line) {
|
|
@@ -230,6 +245,44 @@ export class HeadlessSession {
|
|
|
230
245
|
function stamp() {
|
|
231
246
|
return new Date().toTimeString().slice(0, 8);
|
|
232
247
|
}
|
|
248
|
+
/** Split a launch command into bin + args, respecting single/double quotes. */
|
|
249
|
+
export function parseCommand(command) {
|
|
250
|
+
const tokens = [];
|
|
251
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
252
|
+
let m;
|
|
253
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: tokenizer drain
|
|
254
|
+
while ((m = re.exec(command ?? "")) !== null)
|
|
255
|
+
tokens.push(m[1] ?? m[2] ?? m[3] ?? "");
|
|
256
|
+
return { bin: tokens[0] ?? "", args: tokens.slice(1) };
|
|
257
|
+
}
|
|
258
|
+
/** Structural flags PAGS owns for the Claude stream-json engine — a user command
|
|
259
|
+
* must not override or duplicate these (and must not orphan their values). */
|
|
260
|
+
const RESERVED_CLAUDE_FLAGS = new Set(["-p", "--print", "--input-format", "--output-format", "--verbose", "--resume"]);
|
|
261
|
+
/**
|
|
262
|
+
* Build Claude's argv: our structural stream-json flags + the user's extra args
|
|
263
|
+
* (e.g. `--model`), with reserved flags (and their values) stripped so the user
|
|
264
|
+
* can't clobber the protocol or leave an orphaned positional. `--resume` is added
|
|
265
|
+
* last from our persisted session id, never from the user's command.
|
|
266
|
+
*/
|
|
267
|
+
export function buildClaudeArgs(userArgs, resumeId) {
|
|
268
|
+
const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"];
|
|
269
|
+
for (let i = 0; i < userArgs.length; i++) {
|
|
270
|
+
const a = userArgs[i];
|
|
271
|
+
if (RESERVED_CLAUDE_FLAGS.has(a)) {
|
|
272
|
+
// drop the flag AND its value (when the next token isn't itself a flag)
|
|
273
|
+
if (i + 1 < userArgs.length && !userArgs[i + 1].startsWith("-"))
|
|
274
|
+
i++;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!args.includes(a))
|
|
278
|
+
args.push(a);
|
|
279
|
+
}
|
|
280
|
+
if (!args.includes("--dangerously-skip-permissions"))
|
|
281
|
+
args.push("--dangerously-skip-permissions");
|
|
282
|
+
if (resumeId)
|
|
283
|
+
args.push("--resume", resumeId);
|
|
284
|
+
return args;
|
|
285
|
+
}
|
|
233
286
|
/** Strip ANSI/VT escape sequences so a raw CLI's coloured output reads as plain text. */
|
|
234
287
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escape codes
|
|
235
288
|
function stripAnsi(s) {
|