@proagentstore/cli 0.2.2 → 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.
@@ -10,10 +10,28 @@ export class HeadlessSession {
10
10
  run = "idle";
11
11
  /** Claude Code's own session id (from the init event) — used to --resume. */
12
12
  claudeSessionId = null;
13
+ /** "stream-json" for Claude (structured) · "raw" for any other CLI (stdout capture). */
14
+ mode;
15
+ cmdBin;
16
+ cmdArgs;
17
+ binName;
18
+ /** Wall-clock of the last stdout/stderr byte — drives the raw-mode idle heuristic. */
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;
13
24
  constructor(config) {
14
25
  this.config = config;
15
26
  this.sessionName = `pags-${config.clientType}-${config.id}`;
16
27
  this.claudeSessionId = readState(config.statePath, config.id);
28
+ // Claude is the structured engine; everything else is a raw CLI.
29
+ this.mode = config.clientType === "claude" ? "stream-json" : "raw";
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;
34
+ this.binName = (this.cmdBin.split("/").pop() || this.cmdBin) || "cli";
17
35
  }
18
36
  /** True while the agent process is running. */
19
37
  get alive() {
@@ -21,10 +39,30 @@ export class HeadlessSession {
21
39
  }
22
40
  /** Idle = ready for the next instruction. */
23
41
  get ready() {
24
- return this.alive && this.run === "idle";
42
+ return this.alive && this.runState() === "idle";
25
43
  }
26
44
  runState() {
27
- return this.alive ? (this.run === "thinking" ? "thinking" : "idle") : "idle";
45
+ if (!this.alive)
46
+ return "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";
64
+ }
65
+ return this.run === "thinking" ? "thinking" : "idle";
28
66
  }
29
67
  /** The rendered conversation the brain/console reads (Claude's real output). */
30
68
  snapshot() {
@@ -34,43 +72,38 @@ export class HeadlessSession {
34
72
  start() {
35
73
  if (this.alive)
36
74
  return;
37
- const args = [
38
- "-p",
39
- "--input-format",
40
- "stream-json",
41
- "--output-format",
42
- "stream-json",
43
- "--verbose",
44
- "--dangerously-skip-permissions",
45
- ];
46
- // Continue the prior conversation after a runner restart (state in ~/.claude).
47
- if (this.claudeSessionId)
48
- args.push("--resume", this.claudeSessionId);
49
- this.proc = spawn(this.config.bin ?? "claude", args, {
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];
79
+ this.proc = spawn(this.cmdBin, args, {
50
80
  cwd: this.config.workDir,
51
81
  env: { ...process.env, ...this.config.env },
52
82
  stdio: ["pipe", "pipe", "pipe"],
53
83
  });
54
84
  this.run = "idle";
55
- // MUST handle 'error' — without a listener, a spawn failure (e.g. `claude`
85
+ this.lastOutputAt = Date.now();
86
+ // MUST handle 'error' — without a listener, a spawn failure (e.g. the binary
56
87
  // not on PATH) is thrown as an uncaught exception and crashes the runner.
57
88
  this.proc.on("error", (err) => {
58
89
  this.run = "idle";
59
- this.push(`[cannot run \`${this.config.bin ?? "claude"}\`: ${err.message} — is Claude Code installed and on your PATH?]`);
90
+ this.push(`[cannot run \`${this.cmdBin}\`: ${err.message} — is ${this.binName} installed and on your PATH?]`);
60
91
  this.proc = null;
61
92
  });
62
93
  // Swallow EPIPE when writing to a process that just exited (one-shot turn).
63
94
  this.proc.stdin?.on("error", () => { });
64
95
  this.proc.stdout?.on("data", (d) => this.onStdout(d.toString("utf8")));
65
96
  this.proc.stderr?.on("data", (d) => {
97
+ this.lastOutputAt = Date.now();
98
+ this.sawOutputSinceInput = true;
66
99
  const text = d.toString("utf8").trim();
67
100
  if (text)
68
- this.push(`[claude] ${text}`);
101
+ this.push(`[${this.binName}] ${stripAnsi(text)}`);
69
102
  });
70
103
  this.proc.on("exit", (code) => {
71
104
  this.run = "idle";
72
105
  if (code && code !== 0)
73
- this.push(`[claude exited with code ${code}]`);
106
+ this.push(`[${this.binName} exited with code ${code}]`);
74
107
  // A bad --resume can kill the process instantly; drop it so the next
75
108
  // start is a clean session rather than looping on a dead id.
76
109
  if (code && code !== 0 && this.claudeSessionId) {
@@ -79,15 +112,25 @@ export class HeadlessSession {
79
112
  }
80
113
  });
81
114
  }
82
- /** Send a user turn to Claude (the agent acts on it). */
115
+ /** Send a user turn to the agent (it acts on it). */
83
116
  input(text) {
84
117
  if (!this.alive)
85
118
  this.start();
86
119
  this.push(`\n❯ [${stamp()}] ${text}`); // ❯ — your turn, timestamped
87
120
  this.run = "thinking";
88
- const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
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
89
125
  try {
90
- this.proc?.stdin?.write(`${msg}\n`);
126
+ if (this.mode === "stream-json") {
127
+ const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
128
+ this.proc?.stdin?.write(`${msg}\n`);
129
+ }
130
+ else {
131
+ // Raw CLI: write the line to stdin as if typed.
132
+ this.proc?.stdin?.write(`${text}\n`);
133
+ }
91
134
  }
92
135
  catch {
93
136
  /* process may have died; the next snapshot reports not-alive */
@@ -120,16 +163,37 @@ export class HeadlessSession {
120
163
  }
121
164
  // ── internals ────────────────────────────────────────────────────────────
122
165
  onStdout(chunk) {
166
+ this.lastOutputAt = Date.now();
167
+ this.sawOutputSinceInput = true; // a slow first token must NOT count as idle
123
168
  this.buf += chunk;
124
169
  let nl;
125
170
  // biome-ignore lint/suspicious/noAssignInExpressions: standard line-buffer drain
126
171
  while ((nl = this.buf.indexOf("\n")) >= 0) {
127
172
  const line = this.buf.slice(0, nl).trim();
128
173
  this.buf = this.buf.slice(nl + 1);
129
- if (line)
174
+ if (!line)
175
+ continue;
176
+ if (this.mode === "stream-json")
130
177
  this.handle(line);
178
+ else
179
+ this.pushRaw(line); // raw engine — the line IS the terminal output
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 = "";
131
187
  }
132
188
  }
189
+ /** Raw-engine stdout: strip ANSI control codes and append to the transcript. */
190
+ pushRaw(line) {
191
+ const clean = stripAnsi(line);
192
+ if (clean.trim())
193
+ this.push(clean);
194
+ if (this.transcript.length > 4000)
195
+ this.transcript = this.transcript.slice(-3000);
196
+ }
133
197
  handle(line) {
134
198
  let ev;
135
199
  try {
@@ -181,6 +245,49 @@ export class HeadlessSession {
181
245
  function stamp() {
182
246
  return new Date().toTimeString().slice(0, 8);
183
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
+ }
286
+ /** Strip ANSI/VT escape sequences so a raw CLI's coloured output reads as plain text. */
287
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escape codes
288
+ function stripAnsi(s) {
289
+ return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b[()][AB0-2]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
290
+ }
184
291
  /** Compact a tool_use input object to a single readable line. */
185
292
  function shortInput(input) {
186
293
  if (input == null)
@@ -40,6 +40,7 @@ export class CodingRuntime {
40
40
  id: input.sessionId,
41
41
  workDir,
42
42
  clientType: input.clientType,
43
+ command: input.command,
43
44
  env: input.env,
44
45
  statePath: defaultStatePath(this.reposBaseDir),
45
46
  bin: input.bin,
package/package.json CHANGED
@@ -1,46 +1,46 @@
1
1
  {
2
- "name": "@proagentstore/cli",
3
- "version": "0.2.2",
4
- "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
- "license": "MIT",
6
- "type": "module",
7
- "bin": {
8
- "pags": "dist/index.js"
9
- },
10
- "homepage": "https://github.com/ProAgentStore/platform#readme",
11
- "repository": {
12
- "type": "git",
13
- "url": "git+https://github.com/ProAgentStore/platform.git",
14
- "directory": "packages/cli"
15
- },
16
- "keywords": [
17
- "proagentstore",
18
- "pags",
19
- "cli",
20
- "agents",
21
- "mcp",
22
- "browser-runner"
23
- ],
24
- "publishConfig": {
25
- "access": "public"
26
- },
27
- "scripts": {
28
- "build": "pnpm --filter @proagentstore/browser-runner build && tsup src/index.ts --format esm --dts && node scripts/copy-browser-runner.mjs",
29
- "dev": "tsx src/index.ts",
30
- "typecheck": "tsc --noEmit"
31
- },
32
- "dependencies": {
33
- "chalk": "^5.6.2",
34
- "commander": "^13.0.0",
35
- "playwright": "^1.60.0"
36
- },
37
- "devDependencies": {
38
- "@types/node": "^22.0.0",
39
- "tsup": "^8.4.0",
40
- "tsx": "^4.21.0",
41
- "typescript": "^5.7.0"
42
- },
43
- "files": [
44
- "dist"
45
- ]
2
+ "name": "@proagentstore/cli",
3
+ "version": "0.2.4",
4
+ "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "pags": "dist/index.js"
9
+ },
10
+ "homepage": "https://github.com/ProAgentStore/platform#readme",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/ProAgentStore/platform.git",
14
+ "directory": "packages/cli"
15
+ },
16
+ "keywords": [
17
+ "proagentstore",
18
+ "pags",
19
+ "cli",
20
+ "agents",
21
+ "mcp",
22
+ "browser-runner"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "build": "pnpm --filter @proagentstore/browser-runner build && tsup src/index.ts --format esm --dts && node scripts/copy-browser-runner.mjs",
29
+ "dev": "tsx src/index.ts",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "chalk": "^5.6.2",
34
+ "commander": "^13.0.0",
35
+ "playwright": "^1.60.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22.0.0",
39
+ "tsup": "^8.4.0",
40
+ "tsx": "^4.21.0",
41
+ "typescript": "^5.7.0"
42
+ },
43
+ "files": [
44
+ "dist"
45
+ ]
46
46
  }