@proagentstore/cli 0.1.31 → 0.2.1

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.
@@ -0,0 +1,237 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ export class HeadlessSession {
5
+ config;
6
+ sessionName;
7
+ proc = null;
8
+ buf = "";
9
+ transcript = [];
10
+ run = "idle";
11
+ /** Claude Code's own session id (from the init event) — used to --resume. */
12
+ claudeSessionId = null;
13
+ constructor(config) {
14
+ this.config = config;
15
+ this.sessionName = `pags-${config.clientType}-${config.id}`;
16
+ this.claudeSessionId = readState(config.statePath, config.id);
17
+ }
18
+ /** True while the agent process is running. */
19
+ get alive() {
20
+ return this.proc !== null && this.proc.exitCode === null && !this.proc.killed;
21
+ }
22
+ /** Idle = ready for the next instruction. */
23
+ get ready() {
24
+ return this.alive && this.run === "idle";
25
+ }
26
+ runState() {
27
+ return this.alive ? (this.run === "thinking" ? "thinking" : "idle") : "idle";
28
+ }
29
+ /** The rendered conversation the brain/console reads (Claude's real output). */
30
+ snapshot() {
31
+ return this.transcript.join("\n");
32
+ }
33
+ /** Launch (or resume) the agent process. Idempotent if already alive. */
34
+ start() {
35
+ if (this.alive)
36
+ 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, {
50
+ cwd: this.config.workDir,
51
+ env: { ...process.env, ...this.config.env },
52
+ stdio: ["pipe", "pipe", "pipe"],
53
+ });
54
+ this.run = "idle";
55
+ // MUST handle 'error' — without a listener, a spawn failure (e.g. `claude`
56
+ // not on PATH) is thrown as an uncaught exception and crashes the runner.
57
+ this.proc.on("error", (err) => {
58
+ this.run = "idle";
59
+ this.push(`[cannot run \`${this.config.bin ?? "claude"}\`: ${err.message} — is Claude Code installed and on your PATH?]`);
60
+ this.proc = null;
61
+ });
62
+ // Swallow EPIPE when writing to a process that just exited (one-shot turn).
63
+ this.proc.stdin?.on("error", () => { });
64
+ this.proc.stdout?.on("data", (d) => this.onStdout(d.toString("utf8")));
65
+ this.proc.stderr?.on("data", (d) => {
66
+ const text = d.toString("utf8").trim();
67
+ if (text)
68
+ this.push(`[claude] ${text}`);
69
+ });
70
+ this.proc.on("exit", (code) => {
71
+ this.run = "idle";
72
+ if (code && code !== 0)
73
+ this.push(`[claude exited with code ${code}]`);
74
+ // A bad --resume can kill the process instantly; drop it so the next
75
+ // start is a clean session rather than looping on a dead id.
76
+ if (code && code !== 0 && this.claudeSessionId) {
77
+ this.claudeSessionId = null;
78
+ writeState(this.config.statePath, this.config.id, null);
79
+ }
80
+ });
81
+ }
82
+ /** Send a user turn to Claude (the agent acts on it). */
83
+ input(text) {
84
+ if (!this.alive)
85
+ this.start();
86
+ this.push(`\n❯ ${text}`); // ❯ — your turn, mirrors the old prompt marker
87
+ this.run = "thinking";
88
+ const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
89
+ try {
90
+ this.proc?.stdin?.write(`${msg}\n`);
91
+ }
92
+ catch {
93
+ /* process may have died; the next snapshot reports not-alive */
94
+ }
95
+ }
96
+ /** No TTY in headless mode; control is via messages. Kept for interface parity. */
97
+ key(_keys) {
98
+ /* intentionally a no-op — there are no raw keystrokes without a terminal */
99
+ }
100
+ /** Abort the current turn (SIGINT, like Ctrl-C). The process stays usable. */
101
+ interrupt() {
102
+ try {
103
+ this.proc?.kill("SIGINT");
104
+ }
105
+ catch {
106
+ /* ignore */
107
+ }
108
+ this.run = "idle";
109
+ }
110
+ /** Tear the process down. */
111
+ stop() {
112
+ try {
113
+ this.proc?.kill();
114
+ }
115
+ catch {
116
+ /* ignore */
117
+ }
118
+ this.proc = null;
119
+ this.run = "idle";
120
+ }
121
+ // ── internals ────────────────────────────────────────────────────────────
122
+ onStdout(chunk) {
123
+ this.buf += chunk;
124
+ let nl;
125
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard line-buffer drain
126
+ while ((nl = this.buf.indexOf("\n")) >= 0) {
127
+ const line = this.buf.slice(0, nl).trim();
128
+ this.buf = this.buf.slice(nl + 1);
129
+ if (line)
130
+ this.handle(line);
131
+ }
132
+ }
133
+ handle(line) {
134
+ let ev;
135
+ try {
136
+ ev = JSON.parse(line);
137
+ }
138
+ catch {
139
+ return; // tolerate non-JSON noise
140
+ }
141
+ switch (ev.type) {
142
+ case "system":
143
+ if (ev.subtype === "init" && ev.session_id) {
144
+ this.claudeSessionId = ev.session_id;
145
+ writeState(this.config.statePath, this.config.id, ev.session_id);
146
+ }
147
+ break;
148
+ case "assistant":
149
+ for (const block of ev.message?.content ?? []) {
150
+ if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
151
+ this.push(block.text.trim());
152
+ }
153
+ else if (block.type === "tool_use") {
154
+ this.push(`⚙ ${String(block.name ?? "tool")} ${shortInput(block.input)}`); // ⚙
155
+ }
156
+ }
157
+ break;
158
+ case "user": // tool results come back as a synthetic user message
159
+ for (const block of ev.message?.content ?? []) {
160
+ if (block.type === "tool_result")
161
+ this.push(` ↳ ${toolResult(block.content)}`); // ↳
162
+ }
163
+ break;
164
+ case "result":
165
+ if (ev.is_error)
166
+ this.push(`[error] ${ev.result ?? ev.subtype ?? "failed"}`);
167
+ this.run = "idle"; // the turn is OVER — a fact, not a guess
168
+ break;
169
+ default:
170
+ break;
171
+ }
172
+ // Keep the in-memory transcript bounded.
173
+ if (this.transcript.length > 4000)
174
+ this.transcript = this.transcript.slice(-3000);
175
+ }
176
+ push(line) {
177
+ this.transcript.push(line);
178
+ }
179
+ }
180
+ /** Compact a tool_use input object to a single readable line. */
181
+ function shortInput(input) {
182
+ if (input == null)
183
+ return "";
184
+ try {
185
+ const s = typeof input === "string" ? input : JSON.stringify(input);
186
+ return s.length > 160 ? `${s.slice(0, 160)}…` : s;
187
+ }
188
+ catch {
189
+ return "";
190
+ }
191
+ }
192
+ /** Render a tool_result's content (string, or array of {type:text,text}) to text. */
193
+ function toolResult(content) {
194
+ let text = "";
195
+ if (typeof content === "string")
196
+ text = content;
197
+ else if (Array.isArray(content)) {
198
+ text = content
199
+ .map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
200
+ .join(" ");
201
+ }
202
+ text = text.replace(/\s+/g, " ").trim();
203
+ return text.length > 240 ? `${text.slice(0, 240)}…` : text;
204
+ }
205
+ function loadFile(path) {
206
+ if (!path || !existsSync(path))
207
+ return {};
208
+ try {
209
+ return JSON.parse(readFileSync(path, "utf8"));
210
+ }
211
+ catch {
212
+ return {};
213
+ }
214
+ }
215
+ function readState(path, id) {
216
+ return loadFile(path)[id] ?? null;
217
+ }
218
+ function writeState(path, id, claudeSessionId) {
219
+ if (!path)
220
+ return;
221
+ try {
222
+ const data = loadFile(path);
223
+ if (claudeSessionId)
224
+ data[id] = claudeSessionId;
225
+ else
226
+ delete data[id];
227
+ mkdirSync(dirname(path), { recursive: true });
228
+ writeFileSync(path, JSON.stringify(data));
229
+ }
230
+ catch {
231
+ /* best-effort persistence */
232
+ }
233
+ }
234
+ /** Default location for the resume-id store, under the repos base dir. */
235
+ export function defaultStatePath(reposBaseDir) {
236
+ return join(reposBaseDir, "headless-sessions.json");
237
+ }
@@ -1,7 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join, resolve } from "node:path";
3
- import { CodingSession } from "./session.js";
4
- import { ensureRepo, listSessions as listTmuxSessions, sanitizeSessionName } from "./tmux.js";
3
+ import { defaultStatePath, HeadlessSession } from "./headless.js";
4
+ import { ensureRepo, sanitizeSessionName } from "./tmux.js";
5
5
  /** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
6
6
  const MAX_PANE = 64 * 1024;
7
7
  export class CodingRuntime {
@@ -20,7 +20,7 @@ export class CodingRuntime {
20
20
  }
21
21
  /** Capabilities advertised to PAGS at registration. */
22
22
  static capabilities() {
23
- return ["coding.sessions", "coding.tmux", "human.takeover"];
23
+ return ["coding.sessions", "coding.stream", "human.takeover"];
24
24
  }
25
25
  static taskTypes() {
26
26
  return ["coding.session"];
@@ -36,11 +36,13 @@ export class CodingRuntime {
36
36
  ? resolve(input.workDir.replace(/^~(?=$|\/)/, homedir()))
37
37
  : join(this.reposBaseDir, sanitizeSessionName(input.repoId));
38
38
  ensureRepo(workDir, { cloneUrl: input.cloneUrl, branch: input.branch, token: input.token });
39
- session = new CodingSession({
39
+ session = new HeadlessSession({
40
40
  id: input.sessionId,
41
41
  workDir,
42
42
  clientType: input.clientType,
43
43
  env: input.env,
44
+ statePath: defaultStatePath(this.reposBaseDir),
45
+ bin: input.bin,
44
46
  });
45
47
  this.sessions.set(input.sessionId, session);
46
48
  }
@@ -149,10 +151,9 @@ export class CodingRuntime {
149
151
  this.sessions.clear();
150
152
  this.takeovers.clear();
151
153
  }
152
- /** True if any tmux session this runtime owns is still alive. */
153
- hasLiveTmux() {
154
- const live = new Set(listTmuxSessions());
155
- return [...this.sessions.values()].some((s) => live.has(s.sessionName));
154
+ /** True if any session this runtime owns still has a live agent process. */
155
+ hasLiveSessions() {
156
+ return [...this.sessions.values()].some((s) => s.alive);
156
157
  }
157
158
  require(sessionId) {
158
159
  const session = this.sessions.get(sessionId);
@@ -2,6 +2,16 @@
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { startRunnerServer } from "./server.js";
5
+ // Resilience: a stray error in any runtime must NOT take the whole runner down —
6
+ // that drops the tunnel and forces the user to restart `pags up` (and lose their
7
+ // session). Log it and keep serving. Per-component handlers catch most things;
8
+ // this is the last-resort backstop.
9
+ process.on("uncaughtException", (err) => {
10
+ process.stderr.write(`[runner] uncaught: ${err?.stack || String(err)}\n`);
11
+ });
12
+ process.on("unhandledRejection", (reason) => {
13
+ process.stderr.write(`[runner] unhandled rejection: ${String(reason)}\n`);
14
+ });
5
15
  function arg(name, fallback) {
6
16
  const index = process.argv.indexOf(name);
7
17
  if (index === -1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.1.31",
3
+ "version": "0.2.1",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,149 +0,0 @@
1
- import { capturePane, createSession, killSession, paneTarget, sanitizeSessionName, sendKey, sendText, sessionExists, } from "./tmux.js";
2
- import { handlerFor } from "./handlers.js";
3
- /**
4
- * One AI-coding-CLI session living in a tmux pane.
5
- *
6
- * This is the coding runtime's analogue of a Playwright `Page`: the unit the
7
- * remote brain "sees" (via {@link snapshot}) and "acts on" (via {@link send}),
8
- * and the unit a human attaches to during takeover. The brain/hands split and
9
- * the streaming-until-idle loop mirror the browser runtime exactly — only the
10
- * mechanism (tmux send-keys + capture-pane) differs.
11
- */
12
- export class CodingSession {
13
- config;
14
- sessionName;
15
- target;
16
- handler;
17
- constructor(config) {
18
- this.config = config;
19
- this.sessionName = sanitizeSessionName(`pags-${config.clientType}-${config.id}`);
20
- this.target = paneTarget(this.sessionName);
21
- this.handler = handlerFor(config.clientType);
22
- }
23
- /** True if the underlying tmux session is alive. */
24
- get alive() {
25
- return sessionExists(this.sessionName);
26
- }
27
- /** Launch the CLI inside a fresh tmux session. Idempotent if already alive. */
28
- start() {
29
- if (this.alive)
30
- return;
31
- // Launch a shell first so we can export the BYOK env, then exec the CLI.
32
- createSession(this.sessionName, this.config.workDir);
33
- if (this.config.env) {
34
- for (const [k, v] of Object.entries(this.config.env)) {
35
- // Use printf-safe assignment; values never reach a shell as code because
36
- // we send them as a single literal then press Enter.
37
- sendText(this.target, `export ${k}=${JSON.stringify(v)}`);
38
- sendKey(this.target, "Enter");
39
- }
40
- }
41
- if (this.handler.cliCommand && this.handler.cliCommand !== "bash") {
42
- sendText(this.target, this.handler.cliCommand);
43
- sendKey(this.target, "Enter");
44
- }
45
- }
46
- /** The pane content the brain reasons over (the "snapshot"). */
47
- snapshot(lines = 200) {
48
- return capturePane(this.target, lines);
49
- }
50
- /** True when the CLI is idle and ready for the next message. */
51
- get ready() {
52
- return this.handler.isReady(this.snapshot());
53
- }
54
- /** Send a raw key (Enter, Escape, C-c) — used by takeover and control flows. */
55
- key(name) {
56
- sendKey(this.target, name);
57
- }
58
- /**
59
- * Submit a message to the CLI without waiting for the reply (text + Enter).
60
- * The brain-driven loop calls this, then polls {@link snapshot}/{@link ready}
61
- * until idle — the tmux analogue of the browser runtime's non-blocking act.
62
- */
63
- input(text) {
64
- sendText(this.target, text);
65
- sendKey(this.target, "Enter");
66
- }
67
- /** Interrupt the current run (Ctrl-C), e.g. to stop a runaway CLI. */
68
- interrupt() {
69
- sendKey(this.target, "C-c");
70
- }
71
- /** Current run-state inferred from the pane: idle / thinking / responding. */
72
- runState() {
73
- const pane = this.snapshot();
74
- if (this.handler.isProcessing(pane)) {
75
- return pane.includes("ctrl+c to interrupt") ? "responding" : "thinking";
76
- }
77
- return this.handler.isReady(pane) ? "idle" : "thinking";
78
- }
79
- /**
80
- * Send a message to the CLI and resolve with its extracted response once the
81
- * pane goes idle. Streams intermediate frames through `onStream`.
82
- *
83
- * The completion heuristic is the same staged one AgentCoder proved against
84
- * real CLIs: wait for "processing" to appear, then for the prompt to return
85
- * and output to stabilise, with a hard timeout backstop.
86
- */
87
- async send(message, onStream) {
88
- sendText(this.target, message);
89
- sendKey(this.target, "Enter");
90
- const cfg = this.handler.completion();
91
- const startedAt = Date.now();
92
- const hardCap = 5 * 60 * 1000;
93
- let sawProcessing = false;
94
- let lastContent = "";
95
- let lastChange = Date.now();
96
- let lastEmit = 0;
97
- const emitThrottle = 300;
98
- while (Date.now() - startedAt < hardCap) {
99
- await delay(cfg.pollInterval);
100
- if (!this.alive)
101
- break;
102
- const pane = this.snapshot(5000);
103
- const ready = this.handler.isReady(pane);
104
- const processing = this.handler.isProcessing(pane);
105
- if (onStream) {
106
- const partial = this.handler.extractResponse(pane, message);
107
- if (partial && partial !== lastContent) {
108
- lastChange = Date.now();
109
- if (Date.now() - lastEmit >= emitThrottle) {
110
- lastContent = partial;
111
- lastEmit = Date.now();
112
- await onStream(partial, false);
113
- }
114
- }
115
- }
116
- if (processing || !ready) {
117
- sawProcessing = true;
118
- lastChange = Date.now();
119
- continue;
120
- }
121
- const idle = Date.now() - lastChange;
122
- const elapsed = Date.now() - startedAt;
123
- if (cfg.minWait !== undefined) {
124
- if (ready && elapsed > (sawProcessing ? 0 : cfg.minWait))
125
- break;
126
- }
127
- else {
128
- if (ready && sawProcessing && idle >= cfg.stableThreshold)
129
- break;
130
- if (ready && elapsed > 2000)
131
- break;
132
- if (elapsed >= cfg.forceCompleteAfter)
133
- break;
134
- }
135
- }
136
- const finalPane = this.snapshot(5000);
137
- const response = this.handler.extractResponse(finalPane, message);
138
- if (onStream)
139
- await onStream(response, true);
140
- return response;
141
- }
142
- /** Tear down the tmux session. */
143
- stop() {
144
- killSession(this.sessionName);
145
- }
146
- }
147
- function delay(ms) {
148
- return new Promise((r) => setTimeout(r, ms));
149
- }