@proagentstore/cli 0.1.26 → 0.1.28
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.
- package/dist/browser-runner/coding/handlers.js +111 -0
- package/dist/browser-runner/coding/runtime.js +163 -0
- package/dist/browser-runner/coding/session.js +149 -0
- package/dist/browser-runner/coding/tmux.js +125 -0
- package/dist/browser-runner/index.js +2 -0
- package/dist/browser-runner/runner.js +11 -3
- package/dist/browser-runner/server.js +52 -0
- package/package.json +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-CLI behaviour for the coding runtime.
|
|
3
|
+
*
|
|
4
|
+
* Each AI coding CLI renders its TUI differently, so "is it ready for input?",
|
|
5
|
+
* "is it still working?", and "where does its answer start/end in the pane?" are
|
|
6
|
+
* CLI-specific. These handlers encapsulate that, ported from the AgentCoder
|
|
7
|
+
* `bridge/src/agents/handlers/*`. A `generic` shell handler is included so the
|
|
8
|
+
* runtime (and tests) can drive a plain shell deterministically.
|
|
9
|
+
*/
|
|
10
|
+
/** Shared "find the user's input line, return everything after it up to the next prompt". */
|
|
11
|
+
function sliceAfterInput(captured, userInput, isPromptLine) {
|
|
12
|
+
const lines = captured.split("\n");
|
|
13
|
+
const needle = userInput.slice(0, 25);
|
|
14
|
+
let start = -1;
|
|
15
|
+
if (needle) {
|
|
16
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
17
|
+
if (lines[i].includes(needle)) {
|
|
18
|
+
start = i;
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (start === -1)
|
|
24
|
+
return lines.slice(-200).join("\n").trim();
|
|
25
|
+
let end = lines.length;
|
|
26
|
+
for (let i = lines.length - 1; i > start; i--) {
|
|
27
|
+
if (isPromptLine(lines[i].trim())) {
|
|
28
|
+
end = i;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return lines.slice(start + 1, end).join("\n").trim();
|
|
33
|
+
}
|
|
34
|
+
export class ClaudeHandler {
|
|
35
|
+
clientType = "claude";
|
|
36
|
+
cliCommand = "claude --dangerously-skip-permissions";
|
|
37
|
+
envVar = "ANTHROPIC_API_KEY";
|
|
38
|
+
isProcessing(pane) {
|
|
39
|
+
return pane.includes("ctrl+c to interrupt") || /Working|Thinking|Reading|Searching|Running|Editing|Writing/.test(pane);
|
|
40
|
+
}
|
|
41
|
+
isReady(pane) {
|
|
42
|
+
if (this.isProcessing(pane))
|
|
43
|
+
return false;
|
|
44
|
+
const tail = pane.split("\n").slice(-15).join("\n");
|
|
45
|
+
return (tail.includes("bypass permissions") ||
|
|
46
|
+
tail.includes("? for shortcuts") ||
|
|
47
|
+
/❯\s*$/.test(tail) ||
|
|
48
|
+
/❯ .*↵ send/.test(tail));
|
|
49
|
+
}
|
|
50
|
+
extractResponse(captured, userInput) {
|
|
51
|
+
return sliceAfterInput(captured, userInput, (l) => l === "❯" || l.startsWith("❯ "));
|
|
52
|
+
}
|
|
53
|
+
completion() {
|
|
54
|
+
return { minWait: 1000, stableThreshold: 0, forceCompleteAfter: 5 * 60 * 1000, pollInterval: 500 };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export class GeminiHandler {
|
|
58
|
+
clientType = "gemini";
|
|
59
|
+
cliCommand = "gemini";
|
|
60
|
+
envVar = "GEMINI_API_KEY";
|
|
61
|
+
isProcessing(pane) {
|
|
62
|
+
return /thinking|processing|generating|working|loading/i.test(pane);
|
|
63
|
+
}
|
|
64
|
+
isReady(pane) {
|
|
65
|
+
const last = pane.split("\n").slice(-1)[0]?.trim() ?? "";
|
|
66
|
+
const hasPrompt = /[>$]\s*$/.test(last) || /^>/.test(last);
|
|
67
|
+
return hasPrompt && !this.isProcessing(pane);
|
|
68
|
+
}
|
|
69
|
+
extractResponse(captured, userInput) {
|
|
70
|
+
return sliceAfterInput(captured, userInput, (l) => /^[>$]\s*$/.test(l) || /^[>$]\s+\S/.test(l));
|
|
71
|
+
}
|
|
72
|
+
completion() {
|
|
73
|
+
return { stableThreshold: 1500, forceCompleteAfter: 5 * 60 * 1000, pollInterval: 500 };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Codex / Grok render close enough to a generic prompt; reuse the shell heuristics. */
|
|
77
|
+
export class GenericHandler {
|
|
78
|
+
clientType;
|
|
79
|
+
cliCommand;
|
|
80
|
+
envVar;
|
|
81
|
+
constructor(clientType = "generic", cliCommand = "bash", envVar = "") {
|
|
82
|
+
this.clientType = clientType;
|
|
83
|
+
this.cliCommand = cliCommand;
|
|
84
|
+
this.envVar = envVar;
|
|
85
|
+
}
|
|
86
|
+
isProcessing(pane) {
|
|
87
|
+
const last = pane.split("\n").slice(-1)[0]?.trim() ?? "";
|
|
88
|
+
return /\.\.\.$/.test(last);
|
|
89
|
+
}
|
|
90
|
+
isReady(pane) {
|
|
91
|
+
const last = pane.split("\n").slice(-1)[0]?.trim() ?? "";
|
|
92
|
+
// A shell prompt ends in $, #, >, or % (optionally followed by a cursor space).
|
|
93
|
+
return /[$#>%]\s*$/.test(last) && !this.isProcessing(pane);
|
|
94
|
+
}
|
|
95
|
+
extractResponse(captured, userInput) {
|
|
96
|
+
return sliceAfterInput(captured, userInput, (l) => /[$#>%]\s*$/.test(l));
|
|
97
|
+
}
|
|
98
|
+
completion() {
|
|
99
|
+
return { stableThreshold: 800, forceCompleteAfter: 60 * 1000, pollInterval: 300 };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const HANDLERS = {
|
|
103
|
+
claude: new ClaudeHandler(),
|
|
104
|
+
gemini: new GeminiHandler(),
|
|
105
|
+
codex: new GenericHandler("codex", "codex", "OPENAI_API_KEY"),
|
|
106
|
+
grok: new GenericHandler("grok", "grok", "XAI_API_KEY"),
|
|
107
|
+
generic: new GenericHandler(),
|
|
108
|
+
};
|
|
109
|
+
export function handlerFor(clientType) {
|
|
110
|
+
return HANDLERS[clientType] ?? HANDLERS.generic;
|
|
111
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
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";
|
|
5
|
+
/** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
|
|
6
|
+
const MAX_PANE = 64 * 1024;
|
|
7
|
+
export class CodingRuntime {
|
|
8
|
+
reposBaseDir;
|
|
9
|
+
sessions = new Map();
|
|
10
|
+
/**
|
|
11
|
+
* Active human handoffs keyed by session id. `resolved` flips when the human
|
|
12
|
+
* finishes (console "Resume" / submits a value); the brain workflow polls
|
|
13
|
+
* {@link takeoverStatus} and continues once it does — the tmux analogue of the
|
|
14
|
+
* browser runtime's handoff-status machinery.
|
|
15
|
+
*/
|
|
16
|
+
takeovers = new Map();
|
|
17
|
+
/** Base directory under which repos are cloned (one subdir per repo). */
|
|
18
|
+
constructor(reposBaseDir = join(homedir(), ".config", "proagentstore", "repos")) {
|
|
19
|
+
this.reposBaseDir = reposBaseDir;
|
|
20
|
+
}
|
|
21
|
+
/** Capabilities advertised to PAGS at registration. */
|
|
22
|
+
static capabilities() {
|
|
23
|
+
return ["coding.sessions", "coding.tmux", "human.takeover"];
|
|
24
|
+
}
|
|
25
|
+
static taskTypes() {
|
|
26
|
+
return ["coding.session"];
|
|
27
|
+
}
|
|
28
|
+
/** Start (or return the existing) session and report its first snapshot. */
|
|
29
|
+
start(input) {
|
|
30
|
+
let session = this.sessions.get(input.sessionId);
|
|
31
|
+
if (!session) {
|
|
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));
|
|
35
|
+
ensureRepo(workDir, { cloneUrl: input.cloneUrl, branch: input.branch, token: input.token });
|
|
36
|
+
session = new CodingSession({
|
|
37
|
+
id: input.sessionId,
|
|
38
|
+
workDir,
|
|
39
|
+
clientType: input.clientType,
|
|
40
|
+
env: input.env,
|
|
41
|
+
});
|
|
42
|
+
this.sessions.set(input.sessionId, session);
|
|
43
|
+
}
|
|
44
|
+
session.start();
|
|
45
|
+
return this.snapshot(input.sessionId);
|
|
46
|
+
}
|
|
47
|
+
/** The pane the brain reasons over + the inferred run state. */
|
|
48
|
+
snapshot(sessionId) {
|
|
49
|
+
const session = this.require(sessionId);
|
|
50
|
+
const alive = session.alive;
|
|
51
|
+
const pane = alive ? clip(session.snapshot()) : "";
|
|
52
|
+
return {
|
|
53
|
+
sessionId,
|
|
54
|
+
pane,
|
|
55
|
+
alive,
|
|
56
|
+
ready: alive ? session.ready : false,
|
|
57
|
+
runState: alive ? session.runState() : "idle",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Perform one action, then return the fresh snapshot (non-blocking, like browser act). */
|
|
61
|
+
act(sessionId, action) {
|
|
62
|
+
const session = this.require(sessionId);
|
|
63
|
+
switch (action.kind) {
|
|
64
|
+
case "message":
|
|
65
|
+
session.input(action.text);
|
|
66
|
+
break;
|
|
67
|
+
case "keys":
|
|
68
|
+
session.key(action.keys);
|
|
69
|
+
break;
|
|
70
|
+
case "interrupt":
|
|
71
|
+
session.interrupt();
|
|
72
|
+
break;
|
|
73
|
+
default:
|
|
74
|
+
throw new Error(`Unknown coding action: ${action.kind}`);
|
|
75
|
+
}
|
|
76
|
+
return this.snapshot(sessionId);
|
|
77
|
+
}
|
|
78
|
+
/** Tear down a session. */
|
|
79
|
+
end(sessionId) {
|
|
80
|
+
const session = this.sessions.get(sessionId);
|
|
81
|
+
if (session) {
|
|
82
|
+
session.stop();
|
|
83
|
+
this.sessions.delete(sessionId);
|
|
84
|
+
}
|
|
85
|
+
this.takeovers.delete(sessionId);
|
|
86
|
+
return { ok: true };
|
|
87
|
+
}
|
|
88
|
+
list() {
|
|
89
|
+
return [...this.sessions.entries()].map(([sessionId, s]) => ({
|
|
90
|
+
sessionId,
|
|
91
|
+
alive: s.alive,
|
|
92
|
+
tmuxSession: s.sessionName,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
// ── Human takeover (the "stuck" handoff) ────────────────────────────────
|
|
96
|
+
// A text-frame equivalent of the browser takeover: the console shows the live
|
|
97
|
+
// pane and forwards the human's keystrokes until they Resume.
|
|
98
|
+
/** Begin a handoff (the brain calls this with why it's pausing). */
|
|
99
|
+
beginTakeover(sessionId, opts = {}) {
|
|
100
|
+
this.require(sessionId);
|
|
101
|
+
this.takeovers.set(sessionId, { reason: opts.reason ?? "stuck", label: opts.label ?? "this step", resolved: false });
|
|
102
|
+
return this.snapshot(sessionId);
|
|
103
|
+
}
|
|
104
|
+
takeoverFrame(sessionId) {
|
|
105
|
+
return this.snapshot(sessionId);
|
|
106
|
+
}
|
|
107
|
+
/** Forward a human keystroke/message during takeover. */
|
|
108
|
+
takeoverInput(sessionId, value) {
|
|
109
|
+
const session = this.require(sessionId);
|
|
110
|
+
if (value.keys)
|
|
111
|
+
session.key(value.keys);
|
|
112
|
+
else if (value.text != null)
|
|
113
|
+
session.input(value.text);
|
|
114
|
+
return this.snapshot(sessionId);
|
|
115
|
+
}
|
|
116
|
+
/** The human finished — mark resolved so the brain workflow can resume. */
|
|
117
|
+
resolveTakeover(sessionId, value) {
|
|
118
|
+
const t = this.takeovers.get(sessionId);
|
|
119
|
+
if (t) {
|
|
120
|
+
t.resolved = true;
|
|
121
|
+
t.value = value;
|
|
122
|
+
}
|
|
123
|
+
return { ok: true };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Polled by the brain workflow: has the human resolved the handoff? Defaults to
|
|
127
|
+
* NOT resolved when there's no live entry — if the runner restarted mid-handoff
|
|
128
|
+
* we must NOT auto-resume (the brain would proceed without the value it was told
|
|
129
|
+
* to wait for); the workflow's poll loop times out safely instead.
|
|
130
|
+
*/
|
|
131
|
+
takeoverStatus(sessionId) {
|
|
132
|
+
const t = this.takeovers.get(sessionId);
|
|
133
|
+
return { resolved: t?.resolved ?? false, value: t?.value };
|
|
134
|
+
}
|
|
135
|
+
endTakeover(sessionId) {
|
|
136
|
+
this.takeovers.delete(sessionId);
|
|
137
|
+
return { ok: true };
|
|
138
|
+
}
|
|
139
|
+
isUnderTakeover(sessionId) {
|
|
140
|
+
return this.takeovers.has(sessionId);
|
|
141
|
+
}
|
|
142
|
+
/** Stop every session (runner shutdown). */
|
|
143
|
+
closeAll() {
|
|
144
|
+
for (const s of this.sessions.values())
|
|
145
|
+
s.stop();
|
|
146
|
+
this.sessions.clear();
|
|
147
|
+
this.takeovers.clear();
|
|
148
|
+
}
|
|
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));
|
|
153
|
+
}
|
|
154
|
+
require(sessionId) {
|
|
155
|
+
const session = this.sessions.get(sessionId);
|
|
156
|
+
if (!session)
|
|
157
|
+
throw new Error(`No coding session: ${sessionId}`);
|
|
158
|
+
return session;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function clip(pane) {
|
|
162
|
+
return pane.length > MAX_PANE ? pane.slice(pane.length - MAX_PANE) : pane;
|
|
163
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Low-level tmux primitives for the coding runtime.
|
|
6
|
+
*
|
|
7
|
+
* Ported from the AgentCoder `bridge/src/tmux.ts`, stripped of the Firebase /
|
|
8
|
+
* logger coupling. These are the "hands" the coding runtime uses to drive a
|
|
9
|
+
* local AI coding CLI (Claude / Gemini / Codex) running inside a tmux pane —
|
|
10
|
+
* the tmux analogue of Playwright's page actions in the browser runtime.
|
|
11
|
+
*
|
|
12
|
+
* Everything goes through `execFileSync` with an args array so user/agent text
|
|
13
|
+
* is never shell-interpolated.
|
|
14
|
+
*/
|
|
15
|
+
/** Run tmux with an args array. Throws on non-zero exit. */
|
|
16
|
+
export function tmuxExec(args, timeoutMs = 5000) {
|
|
17
|
+
return execFileSync("tmux", args, { encoding: "utf8", timeout: timeoutMs });
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The pane target for a session. We address the session by name so tmux routes
|
|
21
|
+
* to its active pane — robust against user `base-index`/`pane-base-index` config
|
|
22
|
+
* (a hardcoded `:0.0` breaks when the user sets 1-based indices).
|
|
23
|
+
*/
|
|
24
|
+
export function paneTarget(sessionName) {
|
|
25
|
+
return sessionName;
|
|
26
|
+
}
|
|
27
|
+
/** Send a named key (Enter, Escape, C-c, …) to a pane. */
|
|
28
|
+
export function sendKey(target, key) {
|
|
29
|
+
tmuxExec(["send-keys", "-t", target, key]);
|
|
30
|
+
}
|
|
31
|
+
/** Send literal text to a pane (no shell interpolation, no key interpretation). */
|
|
32
|
+
export function sendText(target, text) {
|
|
33
|
+
tmuxExec(["send-keys", "-t", target, "-l", text]);
|
|
34
|
+
}
|
|
35
|
+
/** True if a tmux session with this name exists. */
|
|
36
|
+
export function sessionExists(sessionName) {
|
|
37
|
+
try {
|
|
38
|
+
execFileSync("tmux", ["has-session", "-t", sessionName], { stdio: "pipe" });
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Create a detached session running an optional command in `workDir`. */
|
|
46
|
+
export function createSession(sessionName, workDir, command) {
|
|
47
|
+
const args = ["new-session", "-d", "-s", sessionName, "-c", workDir, "-x", "200", "-y", "50"];
|
|
48
|
+
if (command)
|
|
49
|
+
args.push(command);
|
|
50
|
+
execFileSync("tmux", args, { stdio: "ignore" });
|
|
51
|
+
}
|
|
52
|
+
/** Kill a session. Returns false if it did not exist. */
|
|
53
|
+
export function killSession(sessionName) {
|
|
54
|
+
try {
|
|
55
|
+
execFileSync("tmux", ["kill-session", "-t", sessionName], { stdio: "pipe" });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** List names of all live sessions. */
|
|
63
|
+
export function listSessions() {
|
|
64
|
+
try {
|
|
65
|
+
const out = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], {
|
|
66
|
+
encoding: "utf8",
|
|
67
|
+
stdio: "pipe",
|
|
68
|
+
});
|
|
69
|
+
return out.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const ANSI = /\x1B\[[0-?]*[ -/]*[@-~]/g;
|
|
76
|
+
/** Strip ANSI escape codes. */
|
|
77
|
+
export function stripAnsi(s) {
|
|
78
|
+
return s.replace(ANSI, "");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Capture the current pane content. `-J` joins wrapped lines so long lines stay
|
|
82
|
+
* intact; `-S -lines` includes scrollback. Returns ANSI-stripped, trimmed text.
|
|
83
|
+
*/
|
|
84
|
+
export function capturePane(target, lines = 200) {
|
|
85
|
+
const captured = tmuxExec(["capture-pane", "-p", "-t", target, "-S", `-${lines}`, "-J"]);
|
|
86
|
+
return stripAnsi(captured).trim();
|
|
87
|
+
}
|
|
88
|
+
/** A safe, collision-resistant tmux session name derived from an arbitrary label. */
|
|
89
|
+
export function sanitizeSessionName(label) {
|
|
90
|
+
return label.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "session";
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Ensure a repo is present at `dir`, cloning it from `cloneUrl` if not. Idempotent
|
|
94
|
+
* — an existing checkout is left alone (no clobber). For private repos a GitHub
|
|
95
|
+
* App installation token is injected as `x-access-token` into an https URL. The
|
|
96
|
+
* coding CLI then runs in this directory.
|
|
97
|
+
*
|
|
98
|
+
* Returns the absolute working directory. Throws on clone failure so the caller
|
|
99
|
+
* can surface it (a session can't start without its repo).
|
|
100
|
+
*/
|
|
101
|
+
export function ensureRepo(dir, opts = {}) {
|
|
102
|
+
// A real checkout (has .git) is reused as-is.
|
|
103
|
+
if (existsSync(join(dir, ".git")))
|
|
104
|
+
return dir;
|
|
105
|
+
if (!opts.cloneUrl) {
|
|
106
|
+
// No source to clone from — make the directory so tmux can cd into it.
|
|
107
|
+
if (!existsSync(dir))
|
|
108
|
+
mkdirSync(dir, { recursive: true });
|
|
109
|
+
return dir;
|
|
110
|
+
}
|
|
111
|
+
// A stale/empty/half-cloned dir (exists but no .git) would make `git clone`
|
|
112
|
+
// fail on a non-empty target — clear it first so the clone is clean.
|
|
113
|
+
if (existsSync(dir))
|
|
114
|
+
rmSync(dir, { recursive: true, force: true });
|
|
115
|
+
let url = opts.cloneUrl;
|
|
116
|
+
if (opts.token && /^https:\/\//.test(url)) {
|
|
117
|
+
url = url.replace(/^https:\/\//, `https://x-access-token:${opts.token}@`);
|
|
118
|
+
}
|
|
119
|
+
const args = ["clone", "--depth", "1"];
|
|
120
|
+
if (opts.branch)
|
|
121
|
+
args.push("--branch", opts.branch);
|
|
122
|
+
args.push(url, dir);
|
|
123
|
+
execFileSync("git", args, { stdio: "pipe", timeout: 180_000 });
|
|
124
|
+
return dir;
|
|
125
|
+
}
|
|
@@ -8,6 +8,7 @@ import { resolveRealChromeProfileDir, seedProfileCopy } from "./browser-profile.
|
|
|
8
8
|
import { performBrowserAction } from "./browser-actions.js";
|
|
9
9
|
import { HumanHandoffError, RunnerInputError } from "./errors.js";
|
|
10
10
|
import { RunnerStore } from "./store.js";
|
|
11
|
+
import { CodingRuntime } from "./coding/runtime.js";
|
|
11
12
|
/** True for a plain object. */
|
|
12
13
|
function isRecord(value) {
|
|
13
14
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -42,12 +43,15 @@ export class LocalRunner {
|
|
|
42
43
|
fileAttachArmed = new WeakSet();
|
|
43
44
|
applyResumePath = null;
|
|
44
45
|
store;
|
|
46
|
+
/** Local tmux coding sessions (the second runtime — AgentCoder port). */
|
|
47
|
+
coding;
|
|
45
48
|
/** Live human-takeover sessions, keyed by task id (the page is kept alive). */
|
|
46
49
|
takeovers = new Map();
|
|
47
50
|
constructor(config) {
|
|
48
51
|
this.config = config;
|
|
49
52
|
mkdirSync(config.dataDir, { recursive: true });
|
|
50
53
|
this.store = new RunnerStore(config.dataDir);
|
|
54
|
+
this.coding = new CodingRuntime(join(config.dataDir, "repos"));
|
|
51
55
|
// Tasks paused/running on a previous process are orphaned now — their
|
|
52
56
|
// pages and takeover sessions are gone. Fail them so the board is clean.
|
|
53
57
|
const expired = this.store.expireInFlightTasks();
|
|
@@ -61,8 +65,8 @@ export class LocalRunner {
|
|
|
61
65
|
controlPlane: "pags",
|
|
62
66
|
runtimePlane: "fags",
|
|
63
67
|
runnerRole: "tool-executor",
|
|
64
|
-
capabilities: CAPABILITIES,
|
|
65
|
-
taskTypes: ["echo", "browser.open", "job.apply_agent"],
|
|
68
|
+
capabilities: [...CAPABILITIES, ...CodingRuntime.capabilities()],
|
|
69
|
+
taskTypes: ["echo", "browser.open", "job.apply_agent", ...CodingRuntime.taskTypes()],
|
|
66
70
|
approvalRequiredFor: [...APPROVAL_REQUIRED_TASKS],
|
|
67
71
|
};
|
|
68
72
|
}
|
|
@@ -140,6 +144,7 @@ export class LocalRunner {
|
|
|
140
144
|
return task;
|
|
141
145
|
}
|
|
142
146
|
async close() {
|
|
147
|
+
this.coding.closeAll();
|
|
143
148
|
await this.browserContext?.close();
|
|
144
149
|
this.browserContext = null;
|
|
145
150
|
}
|
|
@@ -760,7 +765,10 @@ export class LocalRunner {
|
|
|
760
765
|
const task = this.store.getTask(taskId);
|
|
761
766
|
if (task) {
|
|
762
767
|
const success = outcome === "submitted" || outcome === "ready" || outcome === "expired";
|
|
763
|
-
|
|
768
|
+
// 'blocked' = the agent stopped and needs the USER (email verification, can't
|
|
769
|
+
// proceed truthfully) — it's needs-attention, not a failure, so keep it a
|
|
770
|
+
// distinct status (the console shows it as an active ticket, not buried).
|
|
771
|
+
task.status = outcome === "blocked" ? "blocked" : success ? "completed" : "failed";
|
|
764
772
|
task.output = { outcome, detail };
|
|
765
773
|
if (!success)
|
|
766
774
|
task.error = detail || outcome;
|
|
@@ -140,6 +140,58 @@ async function route(runner, req, res) {
|
|
|
140
140
|
await runner.endTakeover(endMatch[1]);
|
|
141
141
|
return json(res, 200, { ok: true });
|
|
142
142
|
}
|
|
143
|
+
// ── Brain-driven coding control (remote LLM drives a tmux coding CLI) ────
|
|
144
|
+
// The tmux analogue of the /browser/* surface: start → capture → act → end.
|
|
145
|
+
if (req.method === "POST" && path === "/coding/start") {
|
|
146
|
+
const b = await readJson(req);
|
|
147
|
+
return json(res, 200, runner.coding.start(b));
|
|
148
|
+
}
|
|
149
|
+
if (req.method === "POST" && path === "/coding/capture") {
|
|
150
|
+
const b = await readJson(req);
|
|
151
|
+
return json(res, 200, runner.coding.snapshot(b.sessionId));
|
|
152
|
+
}
|
|
153
|
+
if (req.method === "POST" && path === "/coding/act") {
|
|
154
|
+
const b = await readJson(req);
|
|
155
|
+
return json(res, 200, runner.coding.act(b.sessionId, b.action));
|
|
156
|
+
}
|
|
157
|
+
if (req.method === "POST" && path === "/coding/end") {
|
|
158
|
+
const b = await readJson(req);
|
|
159
|
+
return json(res, 200, runner.coding.end(b.sessionId));
|
|
160
|
+
}
|
|
161
|
+
if (req.method === "GET" && path === "/coding/sessions") {
|
|
162
|
+
return json(res, 200, { sessions: runner.coding.list() });
|
|
163
|
+
}
|
|
164
|
+
if (req.method === "POST" && path === "/coding/event") {
|
|
165
|
+
// Brain progress events — recorded by PAGS, ignored locally. Accept + ack.
|
|
166
|
+
return json(res, 200, { ok: true });
|
|
167
|
+
}
|
|
168
|
+
// ── Human takeover of a coding session (text frames + keystrokes) ───────
|
|
169
|
+
if (req.method === "POST" && path === "/coding/takeover") {
|
|
170
|
+
const b = await readJson(req);
|
|
171
|
+
return json(res, 200, runner.coding.beginTakeover(b.sessionId, { reason: b.reason, label: b.label }));
|
|
172
|
+
}
|
|
173
|
+
if (req.method === "POST" && path === "/coding/takeover-status") {
|
|
174
|
+
const b = await readJson(req);
|
|
175
|
+
return json(res, 200, runner.coding.takeoverStatus(b.sessionId));
|
|
176
|
+
}
|
|
177
|
+
const codingFrameMatch = path.match(/^\/coding\/takeover\/([^/]+)\/frame$/);
|
|
178
|
+
if (req.method === "GET" && codingFrameMatch) {
|
|
179
|
+
return json(res, 200, runner.coding.takeoverFrame(codingFrameMatch[1]));
|
|
180
|
+
}
|
|
181
|
+
const codingInputMatch = path.match(/^\/coding\/takeover\/([^/]+)\/input$/);
|
|
182
|
+
if (req.method === "POST" && codingInputMatch) {
|
|
183
|
+
const b = await readJson(req);
|
|
184
|
+
return json(res, 200, runner.coding.takeoverInput(codingInputMatch[1], b));
|
|
185
|
+
}
|
|
186
|
+
const codingResolveMatch = path.match(/^\/coding\/takeover\/([^/]+)\/resolve$/);
|
|
187
|
+
if (req.method === "POST" && codingResolveMatch) {
|
|
188
|
+
const b = await readJson(req);
|
|
189
|
+
return json(res, 200, runner.coding.resolveTakeover(codingResolveMatch[1], b.value));
|
|
190
|
+
}
|
|
191
|
+
const codingEndMatch = path.match(/^\/coding\/takeover\/([^/]+)\/end$/);
|
|
192
|
+
if (req.method === "POST" && codingEndMatch) {
|
|
193
|
+
return json(res, 200, runner.coding.endTakeover(codingEndMatch[1]));
|
|
194
|
+
}
|
|
143
195
|
return json(res, 404, { error: "Not found" });
|
|
144
196
|
}
|
|
145
197
|
function authorize(req, config) {
|