@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 tokens = config.command?.trim() ? config.command.trim().split(/\s+/) : [];
27
- // A test/override bin wins; else the command's first word; else `claude`.
28
- this.cmdBin = config.bin ?? tokens[0] ?? "claude";
29
- this.cmdArgs = config.bin ? [] : tokens.slice(1);
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 settle to idle once stdout has
44
- // been quiet for a beat after a turn started.
45
- if (this.mode === "raw" && this.run === "thinking" && Date.now() - this.lastOutputAt > 1500) {
46
- this.run = "idle";
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
- let args;
59
- if (this.mode === "stream-json") {
60
- // Claude: the structured persistent-agent protocol. We own the stream-json
61
- // flags; the user's command may add others (e.g. --model, a key via env).
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
- this.lastOutputAt = Date.now();
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",