@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.
@@ -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
+ }
@@ -39,6 +39,8 @@ Endpoints:
39
39
  POST /tasks/:id/approve
40
40
  POST /tasks/:id/cancel
41
41
  GET /events
42
+ POST /coding/start | /coding/capture | /coding/act | /coding/end
43
+ GET /coding/sessions
42
44
  `);
43
45
  process.exit(0);
44
46
  }
@@ -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
- task.status = success ? "completed" : "failed";
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",