@proagentstore/cli 0.4.36 → 0.4.38
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/runtime.js +26 -3
- package/dist/browser-runner/coding/terminal.js +71 -1
- package/dist/browser-runner/mcp-runtime.js +14 -2
- package/dist/browser-runner/runner.js +6 -2
- package/dist/browser-runner/server.js +14 -6
- package/dist/index.js +32 -5
- package/package.json +1 -1
|
@@ -225,12 +225,35 @@ export class CodingRuntime {
|
|
|
225
225
|
isUnderTakeover(sessionId) {
|
|
226
226
|
return this.takeovers.has(sessionId);
|
|
227
227
|
}
|
|
228
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* Stop every session (runner shutdown).
|
|
230
|
+
*
|
|
231
|
+
* Each `stop()` is isolated (#274 lineage). `stop()` kills a child process, and killing an
|
|
232
|
+
* already-dead or wedged one throws — so a single bad session used to abort the loop, leaving
|
|
233
|
+
* every LATER session's engine running and skipping `sessions.clear()` entirely. The caller
|
|
234
|
+
* (`LocalRunner.close`) wraps this in a `catch {}`, so that escape was silent: the runner
|
|
235
|
+
* reported a clean shutdown while orphaned CLI processes kept editing the user's repo with
|
|
236
|
+
* nothing left holding their ids. Same shape as the `browserContext.close()` leak — a throw
|
|
237
|
+
* on the teardown path is what MAKES the leak, so every session is stopped independently and
|
|
238
|
+
* the maps always clear.
|
|
239
|
+
*/
|
|
229
240
|
closeAll() {
|
|
230
|
-
|
|
231
|
-
|
|
241
|
+
const failures = [];
|
|
242
|
+
for (const s of this.sessions.values()) {
|
|
243
|
+
try {
|
|
244
|
+
s.stop();
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
failures.push(e);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
232
250
|
this.sessions.clear();
|
|
233
251
|
this.takeovers.clear();
|
|
252
|
+
if (failures.length) {
|
|
253
|
+
// Cleared the state, then report: teardown completed as far as it could, but the
|
|
254
|
+
// operator needs to know a child process may have survived it.
|
|
255
|
+
throw new AggregateError(failures, `${failures.length} coding session(s) failed to stop`);
|
|
256
|
+
}
|
|
234
257
|
}
|
|
235
258
|
/** True if any session this runtime owns still has a live agent process. */
|
|
236
259
|
hasLiveSessions() {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
-
import { capturePane, createSession, killSession, listSessionsDetailed, runCommand as tmuxRunCommand, sendKey as tmuxSendKey, sendText as tmuxSendText, sessionExists } from "./tmux.js";
|
|
4
|
+
import { capturePane, createSession, killSession, listSessionsDetailed, runCommand as tmuxRunCommand, sendKey as tmuxSendKey, sendText as tmuxSendText, sessionExists, tmuxExec } from "./tmux.js";
|
|
5
5
|
function shellPath() {
|
|
6
6
|
return process.env.SHELL || "/bin/zsh";
|
|
7
7
|
}
|
|
@@ -51,6 +51,76 @@ export function captureTerminalTarget(target, opts = {}) {
|
|
|
51
51
|
return itermSessionScript(t.id, "return contents of theSession");
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The foreground command running in a target right now — `claude`, `node`, `zsh`, … (#348).
|
|
56
|
+
*
|
|
57
|
+
* The platform cannot measure what an AI CLI inside a pane spends: a pane holds rendered text, not
|
|
58
|
+
* the stream-json `result` event `headless.ts` reads. So the cloud records the drive as UNMETERED
|
|
59
|
+
* instead, and this is the one fact that makes that record specific rather than generic. It is a
|
|
60
|
+
* name, never a number — no cost figure is ever parsed out of pane output, because a scraped
|
|
61
|
+
* dollar amount would be a guess dressed as a measurement.
|
|
62
|
+
*
|
|
63
|
+
* Returns `null` when it cannot be read (no such target, iTerm2, a backend that will not say).
|
|
64
|
+
* `null` means UNREADABLE and must not be flattened into "nothing was running" upstream: the
|
|
65
|
+
* distinction is the entire point of recording this at all.
|
|
66
|
+
*/
|
|
67
|
+
export function activeTerminalCommand(target, backend) {
|
|
68
|
+
let t;
|
|
69
|
+
try {
|
|
70
|
+
t = splitTerminalTarget(target, backend);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
switch (t.backend) {
|
|
77
|
+
case "tmux": {
|
|
78
|
+
const out = tmuxExec(["display-message", "-p", "-t", t.id, "#{pane_current_command}"]).trim();
|
|
79
|
+
return out || null;
|
|
80
|
+
}
|
|
81
|
+
case "kitty":
|
|
82
|
+
return kittyForegroundCommand(t.id);
|
|
83
|
+
// iTerm2 exposes no cheap, reliable foreground-process query. Saying so is the honest
|
|
84
|
+
// answer; guessing from the pane's last line would not be.
|
|
85
|
+
case "iterm2":
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** The foreground process of a kitty window, from `kitty @ ls`. Null if it can't be determined. */
|
|
94
|
+
function kittyForegroundCommand(id) {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(kittyExec(["@", "ls"], 5000));
|
|
97
|
+
if (!Array.isArray(parsed))
|
|
98
|
+
return null;
|
|
99
|
+
for (const osWindow of parsed) {
|
|
100
|
+
const tabs = Array.isArray(osWindow.tabs) ? osWindow.tabs : [];
|
|
101
|
+
for (const tab of tabs) {
|
|
102
|
+
const windows = Array.isArray(tab.windows) ? tab.windows : [];
|
|
103
|
+
for (const win of windows) {
|
|
104
|
+
const row = win;
|
|
105
|
+
if (String(row.id ?? "") !== id)
|
|
106
|
+
continue;
|
|
107
|
+
const procs = Array.isArray(row.foreground_processes) ? row.foreground_processes : [];
|
|
108
|
+
for (const p of procs) {
|
|
109
|
+
const cmdline = p.cmdline;
|
|
110
|
+
const first = Array.isArray(cmdline) ? String(cmdline[0] ?? "").trim() : "";
|
|
111
|
+
if (first)
|
|
112
|
+
return first;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
54
124
|
export function runTerminalCommand(target, command, backend) {
|
|
55
125
|
const t = splitTerminalTarget(target, backend);
|
|
56
126
|
if (!command.trim())
|
|
@@ -136,13 +136,25 @@ export class McpRuntime {
|
|
|
136
136
|
this.client = new Client({ name: "pags-runner", version: "1.0.0" });
|
|
137
137
|
await this.client.connect(clientTransport);
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* The connected client, or a diagnosis. `client` is unset before `start()` AND cleared by
|
|
141
|
+
* `stop()`, so both call sites below are reachable with it missing — a use-after-stop is the
|
|
142
|
+
* likely one, since `stop()` runs on teardown while a slow in-flight action is still landing.
|
|
143
|
+
* A bare `this.client!` turned that into "Cannot read properties of undefined (reading
|
|
144
|
+
* 'callTool')" at the relay boundary, which names neither the runtime nor the lifecycle.
|
|
145
|
+
*/
|
|
146
|
+
connected() {
|
|
147
|
+
if (!this.client)
|
|
148
|
+
throw new Error("MCP runtime is not running — call start() first (or it was already stopped).");
|
|
149
|
+
return this.client;
|
|
150
|
+
}
|
|
139
151
|
/** The standard Playwright MCP tool schemas — advertised to the cloud brain verbatim. */
|
|
140
152
|
async listTools() {
|
|
141
|
-
const res = await this.
|
|
153
|
+
const res = await this.connected().listTools();
|
|
142
154
|
return res.tools;
|
|
143
155
|
}
|
|
144
156
|
async callTool(name, args = {}) {
|
|
145
|
-
return (await this.
|
|
157
|
+
return (await this.connected().callTool({ name, arguments: args }));
|
|
146
158
|
}
|
|
147
159
|
/** Text of the last tool result (the standard tools return their output as text content). */
|
|
148
160
|
textOf(res) {
|
|
@@ -193,8 +193,12 @@ export class LocalRunner {
|
|
|
193
193
|
try {
|
|
194
194
|
this.coding.closeAll();
|
|
195
195
|
}
|
|
196
|
-
catch {
|
|
197
|
-
//
|
|
196
|
+
catch (e) {
|
|
197
|
+
// A stuck coding session must not block the browser teardown below — but it must not
|
|
198
|
+
// vanish either. `closeAll` now stops every session independently and only throws to
|
|
199
|
+
// report which ones refused, so reaching here means a child process may have outlived
|
|
200
|
+
// the runner. Silence here is what let that read as a clean shutdown.
|
|
201
|
+
console.warn(`[runner] ${e instanceof Error ? e.message : String(e)} — a CLI process may still be running; check \`ps\` if a repo keeps changing`);
|
|
198
202
|
}
|
|
199
203
|
await this.mcp?.stop().catch(() => undefined);
|
|
200
204
|
this.mcp = null;
|
|
@@ -304,7 +304,10 @@ async function route(runner, req, res) {
|
|
|
304
304
|
sendText(session, String(b.text));
|
|
305
305
|
for (const k of b.keys ?? [])
|
|
306
306
|
sendKey(session, String(k));
|
|
307
|
-
|
|
307
|
+
// `activeCommand` rides along on every WRITE so the cloud can record what it just drove
|
|
308
|
+
// (#348). It is a process name, never a cost — see activeTerminalCommand's comment.
|
|
309
|
+
const { activeTerminalCommand } = await import("./coding/terminal.js");
|
|
310
|
+
return json(res, 200, { session, pane: capturePane(session, 200), activeCommand: activeTerminalCommand(session, "tmux") });
|
|
308
311
|
}
|
|
309
312
|
if (req.method === "POST" && path === "/tmux/run") {
|
|
310
313
|
const { runCommand, capturePane, sessionExists } = await import("./coding/tmux.js");
|
|
@@ -318,7 +321,8 @@ async function route(runner, req, res) {
|
|
|
318
321
|
if (!sessionExists(session))
|
|
319
322
|
return json(res, 404, { error: `No tmux session "${session}".` });
|
|
320
323
|
runCommand(session, command);
|
|
321
|
-
|
|
324
|
+
const { activeTerminalCommand } = await import("./coding/terminal.js");
|
|
325
|
+
return json(res, 200, { session, command, pane: capturePane(session, 200), activeCommand: activeTerminalCommand(session, "tmux") });
|
|
322
326
|
}
|
|
323
327
|
if (req.method === "POST" && path === "/tmux/session") {
|
|
324
328
|
const { createSession, killSession, sessionExists } = await import("./coding/tmux.js");
|
|
@@ -357,7 +361,7 @@ async function route(runner, req, res) {
|
|
|
357
361
|
return json(res, 200, { target, pane: captureTerminalTarget(target, { backend, lines: b.lines }) });
|
|
358
362
|
}
|
|
359
363
|
if (req.method === "POST" && path === "/terminal/run") {
|
|
360
|
-
const { runTerminalCommand } = await import("./coding/terminal.js");
|
|
364
|
+
const { runTerminalCommand, activeTerminalCommand } = await import("./coding/terminal.js");
|
|
361
365
|
const b = await readJson(req);
|
|
362
366
|
const target = String(b.target || "").trim();
|
|
363
367
|
const command = String(b.command ?? "");
|
|
@@ -366,16 +370,20 @@ async function route(runner, req, res) {
|
|
|
366
370
|
if (!command.trim())
|
|
367
371
|
return json(res, 400, { error: "A `command` is required." });
|
|
368
372
|
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
|
|
369
|
-
|
|
373
|
+
const pane = runTerminalCommand(target, command, backend);
|
|
374
|
+
// Read AFTER the command lands, so `claude "fix x"` reports `claude` rather than the shell
|
|
375
|
+
// that was sitting there a moment earlier (#348).
|
|
376
|
+
return json(res, 200, { target, command, pane, activeCommand: activeTerminalCommand(target, backend) });
|
|
370
377
|
}
|
|
371
378
|
if (req.method === "POST" && path === "/terminal/send") {
|
|
372
|
-
const { sendTerminalKeys } = await import("./coding/terminal.js");
|
|
379
|
+
const { sendTerminalKeys, activeTerminalCommand } = await import("./coding/terminal.js");
|
|
373
380
|
const b = await readJson(req);
|
|
374
381
|
const target = String(b.target || "").trim();
|
|
375
382
|
if (!target)
|
|
376
383
|
return json(res, 400, { error: "A `target` is required." });
|
|
377
384
|
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
|
|
378
|
-
|
|
385
|
+
const pane = sendTerminalKeys(target, { backend, text: b.text == null ? undefined : String(b.text), keys: b.keys ?? [] });
|
|
386
|
+
return json(res, 200, { target, pane, activeCommand: activeTerminalCommand(target, backend) });
|
|
379
387
|
}
|
|
380
388
|
if (req.method === "POST" && path === "/terminal/session") {
|
|
381
389
|
const { createTerminalTarget, killTerminalTarget } = await import("./coding/terminal.js");
|
package/dist/index.js
CHANGED
|
@@ -533,7 +533,14 @@ var publishCommand = new Command5("publish").description("Publish an agent to Pr
|
|
|
533
533
|
writeError("No agent.json found. Run `pags init` first.");
|
|
534
534
|
process.exit(1);
|
|
535
535
|
}
|
|
536
|
-
|
|
536
|
+
let manifest;
|
|
537
|
+
try {
|
|
538
|
+
manifest = JSON.parse(readFileSync3(manifestPath, "utf-8"));
|
|
539
|
+
} catch (e) {
|
|
540
|
+
writeError(`agent.json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
541
|
+
process.exit(1);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
537
544
|
const slug = manifest.id;
|
|
538
545
|
if (!slug) {
|
|
539
546
|
writeError("agent.json missing id");
|
|
@@ -592,8 +599,12 @@ var publishCommand = new Command5("publish").description("Publish an agent to Pr
|
|
|
592
599
|
cwd: dir,
|
|
593
600
|
stdio: "inherit"
|
|
594
601
|
});
|
|
595
|
-
} catch {
|
|
596
|
-
|
|
602
|
+
} catch (e) {
|
|
603
|
+
writeError(`
|
|
604
|
+
Push failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
605
|
+
writeError(" Nothing was published \u2014 fix the push (pull/rebase, or check your GitHub auth) and retry.\n");
|
|
606
|
+
process.exit(1);
|
|
607
|
+
return;
|
|
597
608
|
}
|
|
598
609
|
}
|
|
599
610
|
writeLine("\n Registering agent in store...");
|
|
@@ -874,10 +885,23 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
874
885
|
writeLine(` Agents: ${instanceIds.length} instance${instanceIds.length === 1 ? "" : "s"}`);
|
|
875
886
|
writeLine(" No cloudflared needed. Ctrl+C to disconnect.");
|
|
876
887
|
writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
888
|
+
let heartbeatFailing = false;
|
|
877
889
|
const heartbeat = () => {
|
|
878
890
|
const timer = setTimeout(async () => {
|
|
891
|
+
let failure = null;
|
|
879
892
|
for (const id of [...attached.keys()]) {
|
|
880
|
-
|
|
893
|
+
try {
|
|
894
|
+
await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, { runnerNode });
|
|
895
|
+
} catch (e) {
|
|
896
|
+
failure = e instanceof Error ? e.message : String(e);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (failure && !heartbeatFailing) {
|
|
900
|
+
heartbeatFailing = true;
|
|
901
|
+
writeError(`Heartbeat failed: ${failure} \u2014 the console will show this machine as OFFLINE until it recovers. The relay itself is still connected; don't run \`pags up --force\` elsewhere.`);
|
|
902
|
+
} else if (!failure && heartbeatFailing) {
|
|
903
|
+
heartbeatFailing = false;
|
|
904
|
+
writeLine("Heartbeat recovered \u2014 this machine reads as online again.");
|
|
881
905
|
}
|
|
882
906
|
heartbeat();
|
|
883
907
|
}, 3e4);
|
|
@@ -1499,7 +1523,10 @@ var upCommand = new Command7("up").description("Start the browser runner for all
|
|
|
1499
1523
|
stdio: "inherit",
|
|
1500
1524
|
env: process.env
|
|
1501
1525
|
});
|
|
1502
|
-
} catch {
|
|
1526
|
+
} catch (e) {
|
|
1527
|
+
const status = e.status;
|
|
1528
|
+
writeLine(` Restart failed${typeof status === "number" ? ` (exit ${status})` : ""} \u2014 the runner is NOT running. Run 'pags up' again.`);
|
|
1529
|
+
process.exit(typeof status === "number" && status !== 0 ? status : 1);
|
|
1503
1530
|
}
|
|
1504
1531
|
process.exit(0);
|
|
1505
1532
|
}
|