@proagentstore/cli 0.1.30 → 0.2.0

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,228 @@
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
+ this.proc.stdout?.on("data", (d) => this.onStdout(d.toString("utf8")));
56
+ this.proc.stderr?.on("data", (d) => {
57
+ const text = d.toString("utf8").trim();
58
+ if (text)
59
+ this.push(`[claude] ${text}`);
60
+ });
61
+ this.proc.on("exit", (code) => {
62
+ this.run = "idle";
63
+ if (code && code !== 0)
64
+ this.push(`[claude exited with code ${code}]`);
65
+ // A bad --resume can kill the process instantly; drop it so the next
66
+ // start is a clean session rather than looping on a dead id.
67
+ if (code && code !== 0 && this.claudeSessionId) {
68
+ this.claudeSessionId = null;
69
+ writeState(this.config.statePath, this.config.id, null);
70
+ }
71
+ });
72
+ }
73
+ /** Send a user turn to Claude (the agent acts on it). */
74
+ input(text) {
75
+ if (!this.alive)
76
+ this.start();
77
+ this.push(`\n❯ ${text}`); // ❯ — your turn, mirrors the old prompt marker
78
+ this.run = "thinking";
79
+ const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
80
+ try {
81
+ this.proc?.stdin?.write(`${msg}\n`);
82
+ }
83
+ catch {
84
+ /* process may have died; the next snapshot reports not-alive */
85
+ }
86
+ }
87
+ /** No TTY in headless mode; control is via messages. Kept for interface parity. */
88
+ key(_keys) {
89
+ /* intentionally a no-op — there are no raw keystrokes without a terminal */
90
+ }
91
+ /** Abort the current turn (SIGINT, like Ctrl-C). The process stays usable. */
92
+ interrupt() {
93
+ try {
94
+ this.proc?.kill("SIGINT");
95
+ }
96
+ catch {
97
+ /* ignore */
98
+ }
99
+ this.run = "idle";
100
+ }
101
+ /** Tear the process down. */
102
+ stop() {
103
+ try {
104
+ this.proc?.kill();
105
+ }
106
+ catch {
107
+ /* ignore */
108
+ }
109
+ this.proc = null;
110
+ this.run = "idle";
111
+ }
112
+ // ── internals ────────────────────────────────────────────────────────────
113
+ onStdout(chunk) {
114
+ this.buf += chunk;
115
+ let nl;
116
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard line-buffer drain
117
+ while ((nl = this.buf.indexOf("\n")) >= 0) {
118
+ const line = this.buf.slice(0, nl).trim();
119
+ this.buf = this.buf.slice(nl + 1);
120
+ if (line)
121
+ this.handle(line);
122
+ }
123
+ }
124
+ handle(line) {
125
+ let ev;
126
+ try {
127
+ ev = JSON.parse(line);
128
+ }
129
+ catch {
130
+ return; // tolerate non-JSON noise
131
+ }
132
+ switch (ev.type) {
133
+ case "system":
134
+ if (ev.subtype === "init" && ev.session_id) {
135
+ this.claudeSessionId = ev.session_id;
136
+ writeState(this.config.statePath, this.config.id, ev.session_id);
137
+ }
138
+ break;
139
+ case "assistant":
140
+ for (const block of ev.message?.content ?? []) {
141
+ if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
142
+ this.push(block.text.trim());
143
+ }
144
+ else if (block.type === "tool_use") {
145
+ this.push(`⚙ ${String(block.name ?? "tool")} ${shortInput(block.input)}`); // ⚙
146
+ }
147
+ }
148
+ break;
149
+ case "user": // tool results come back as a synthetic user message
150
+ for (const block of ev.message?.content ?? []) {
151
+ if (block.type === "tool_result")
152
+ this.push(` ↳ ${toolResult(block.content)}`); // ↳
153
+ }
154
+ break;
155
+ case "result":
156
+ if (ev.is_error)
157
+ this.push(`[error] ${ev.result ?? ev.subtype ?? "failed"}`);
158
+ this.run = "idle"; // the turn is OVER — a fact, not a guess
159
+ break;
160
+ default:
161
+ break;
162
+ }
163
+ // Keep the in-memory transcript bounded.
164
+ if (this.transcript.length > 4000)
165
+ this.transcript = this.transcript.slice(-3000);
166
+ }
167
+ push(line) {
168
+ this.transcript.push(line);
169
+ }
170
+ }
171
+ /** Compact a tool_use input object to a single readable line. */
172
+ function shortInput(input) {
173
+ if (input == null)
174
+ return "";
175
+ try {
176
+ const s = typeof input === "string" ? input : JSON.stringify(input);
177
+ return s.length > 160 ? `${s.slice(0, 160)}…` : s;
178
+ }
179
+ catch {
180
+ return "";
181
+ }
182
+ }
183
+ /** Render a tool_result's content (string, or array of {type:text,text}) to text. */
184
+ function toolResult(content) {
185
+ let text = "";
186
+ if (typeof content === "string")
187
+ text = content;
188
+ else if (Array.isArray(content)) {
189
+ text = content
190
+ .map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
191
+ .join(" ");
192
+ }
193
+ text = text.replace(/\s+/g, " ").trim();
194
+ return text.length > 240 ? `${text.slice(0, 240)}…` : text;
195
+ }
196
+ function loadFile(path) {
197
+ if (!path || !existsSync(path))
198
+ return {};
199
+ try {
200
+ return JSON.parse(readFileSync(path, "utf8"));
201
+ }
202
+ catch {
203
+ return {};
204
+ }
205
+ }
206
+ function readState(path, id) {
207
+ return loadFile(path)[id] ?? null;
208
+ }
209
+ function writeState(path, id, claudeSessionId) {
210
+ if (!path)
211
+ return;
212
+ try {
213
+ const data = loadFile(path);
214
+ if (claudeSessionId)
215
+ data[id] = claudeSessionId;
216
+ else
217
+ delete data[id];
218
+ mkdirSync(dirname(path), { recursive: true });
219
+ writeFileSync(path, JSON.stringify(data));
220
+ }
221
+ catch {
222
+ /* best-effort persistence */
223
+ }
224
+ }
225
+ /** Default location for the resume-id store, under the repos base dir. */
226
+ export function defaultStatePath(reposBaseDir) {
227
+ return join(reposBaseDir, "headless-sessions.json");
228
+ }
@@ -1,7 +1,7 @@
1
1
  import { homedir } from "node:os";
2
- import { join } from "node:path";
3
- import { CodingSession } from "./session.js";
4
- import { ensureRepo, listSessions as listTmuxSessions, sanitizeSessionName } from "./tmux.js";
2
+ import { join, resolve } from "node:path";
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"];
@@ -30,14 +30,19 @@ export class CodingRuntime {
30
30
  let session = this.sessions.get(input.sessionId);
31
31
  if (!session) {
32
32
  // Resolve the working dir and ensure the repo is present (clone on first
33
- // start). Without this the CLI would launch in a non-existent path.
34
- const workDir = input.workDir || join(this.reposBaseDir, sanitizeSessionName(input.repoId));
33
+ // start). A user-supplied local path may use ~ — expand it; otherwise
34
+ // clone into a managed dir. Without this the CLI would launch nowhere.
35
+ const workDir = input.workDir
36
+ ? resolve(input.workDir.replace(/^~(?=$|\/)/, homedir()))
37
+ : join(this.reposBaseDir, sanitizeSessionName(input.repoId));
35
38
  ensureRepo(workDir, { cloneUrl: input.cloneUrl, branch: input.branch, token: input.token });
36
- session = new CodingSession({
39
+ session = new HeadlessSession({
37
40
  id: input.sessionId,
38
41
  workDir,
39
42
  clientType: input.clientType,
40
43
  env: input.env,
44
+ statePath: defaultStatePath(this.reposBaseDir),
45
+ bin: input.bin,
41
46
  });
42
47
  this.sessions.set(input.sessionId, session);
43
48
  }
@@ -146,10 +151,9 @@ export class CodingRuntime {
146
151
  this.sessions.clear();
147
152
  this.takeovers.clear();
148
153
  }
149
- /** True if any tmux session this runtime owns is still alive. */
150
- hasLiveTmux() {
151
- const live = new Set(listTmuxSessions());
152
- 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);
153
157
  }
154
158
  require(sessionId) {
155
159
  const session = this.sessions.get(sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.1.30",
3
+ "version": "0.2.0",
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
- }