@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 {
|
|
4
|
-
import { ensureRepo,
|
|
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.
|
|
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).
|
|
34
|
-
|
|
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
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
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,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
|
-
}
|