@proagentstore/cli 0.4.19 → 0.4.21
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.
|
@@ -97,6 +97,24 @@ export function runRepoGit(workDir, cmd, opts = {}) {
|
|
|
97
97
|
const truncated = out.length > cap;
|
|
98
98
|
return { cmd, output: truncated ? out.slice(0, cap) : out, truncated };
|
|
99
99
|
}
|
|
100
|
+
/** Read the repo's `origin` remote URL — used to auto-associate a local checkout with its
|
|
101
|
+
* GitHub repo (so build status can query Actions). Fixed argv, no shell, no user input;
|
|
102
|
+
* returns null when it's not a git repo or has no `origin` remote. */
|
|
103
|
+
export function readGitRemoteOrigin(workDir) {
|
|
104
|
+
if (!existsSync(resolve(workDir, ".git")))
|
|
105
|
+
return null;
|
|
106
|
+
try {
|
|
107
|
+
const out = execFileSync("git", ["config", "--get", "remote.origin.url"], {
|
|
108
|
+
cwd: workDir,
|
|
109
|
+
encoding: "utf-8",
|
|
110
|
+
timeout: 10_000,
|
|
111
|
+
});
|
|
112
|
+
return out.trim() || null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
100
118
|
const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", ".turbo", "coverage", ".wrangler"]);
|
|
101
119
|
/** Bounded recursive file tree (names/type/size only — no contents). */
|
|
102
120
|
export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { defaultStatePath, HeadlessSession } from "./headless.js";
|
|
4
|
-
import { InspectError, readRepoFile, repoTree, runRepoGit } from "./inspect.js";
|
|
4
|
+
import { InspectError, readGitRemoteOrigin, readRepoFile, repoTree, runRepoGit } from "./inspect.js";
|
|
5
5
|
import { ensureRepo, sanitizeSessionName } from "./tmux.js";
|
|
6
6
|
/** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
|
|
7
7
|
const MAX_PANE = 64 * 1024;
|
|
@@ -53,6 +53,11 @@ export class CodingRuntime {
|
|
|
53
53
|
tree(input) {
|
|
54
54
|
return repoTree(this.resolveWorkDir(input), input.path, input.maxDepth, input.maxEntries);
|
|
55
55
|
}
|
|
56
|
+
/** Read the local checkout's `origin` remote URL — lets PAGS auto-associate a
|
|
57
|
+
* local-path repo with its GitHub owner/repo (so build status can query Actions). */
|
|
58
|
+
gitRemote(input) {
|
|
59
|
+
return { remote: readGitRemoteOrigin(this.resolveWorkDir(input)) };
|
|
60
|
+
}
|
|
56
61
|
static taskTypes() {
|
|
57
62
|
return ["coding.session"];
|
|
58
63
|
}
|
|
@@ -72,6 +72,47 @@ export function listSessions() {
|
|
|
72
72
|
return [];
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Rich listing of every live session with the signal a UI/agent needs to tell them
|
|
77
|
+
* apart: window count, attach state, and what's running in the active pane. Returns
|
|
78
|
+
* an empty array when no tmux server is running (never throws).
|
|
79
|
+
*/
|
|
80
|
+
export function listSessionsDetailed() {
|
|
81
|
+
try {
|
|
82
|
+
// Tab-separated so a session name containing spaces can't split a field.
|
|
83
|
+
const fmt = "#{session_name}\t#{session_windows}\t#{session_attached}\t#{pane_current_command}\t#{window_name}\t#{session_created}";
|
|
84
|
+
const out = execFileSync("tmux", ["list-sessions", "-F", fmt], { encoding: "utf8", stdio: "pipe" });
|
|
85
|
+
return out
|
|
86
|
+
.split("\n")
|
|
87
|
+
.map((l) => l.trim())
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.map((line) => {
|
|
90
|
+
const [name, windows, attached, activeCommand, activeWindow, created] = line.split("\t");
|
|
91
|
+
return {
|
|
92
|
+
name: name ?? "",
|
|
93
|
+
windows: Number(windows) || 0,
|
|
94
|
+
attached: attached === "1",
|
|
95
|
+
activeCommand: activeCommand ?? "",
|
|
96
|
+
activeWindow: activeWindow ?? "",
|
|
97
|
+
created: created ?? "",
|
|
98
|
+
};
|
|
99
|
+
})
|
|
100
|
+
.filter((s) => s.name);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Type a command line into a session's active pane and press Enter — the convenience
|
|
108
|
+
* wrapper over sendText + sendKey for "run this shell/git command". The text is sent
|
|
109
|
+
* literally (`-l`), so it is never shell-interpreted by tmux; the receiving pane's
|
|
110
|
+
* shell/CLI runs it exactly as a human would have typed it.
|
|
111
|
+
*/
|
|
112
|
+
export function runCommand(target, command) {
|
|
113
|
+
sendText(target, command);
|
|
114
|
+
sendKey(target, "Enter");
|
|
115
|
+
}
|
|
75
116
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes from tmux output.
|
|
76
117
|
const ANSI = /\x1B\[[0-?]*[ -/]*[@-~]/g;
|
|
77
118
|
/** Strip ANSI escape codes. */
|
|
@@ -242,6 +242,15 @@ async function route(runner, req, res) {
|
|
|
242
242
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
|
+
if (req.method === "POST" && path === "/coding/git-remote") {
|
|
246
|
+
const b = await readJson(req);
|
|
247
|
+
try {
|
|
248
|
+
return json(res, 200, runner.coding.gitRemote(b));
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
245
254
|
if (req.method === "POST" && path === "/coding/tree") {
|
|
246
255
|
const b = await readJson(req);
|
|
247
256
|
try {
|
|
@@ -282,6 +291,72 @@ async function route(runner, req, res) {
|
|
|
282
291
|
if (req.method === "POST" && codingEndMatch) {
|
|
283
292
|
return json(res, 200, runner.coding.endTakeover(codingEndMatch[1]));
|
|
284
293
|
}
|
|
294
|
+
// ── tmux connector ──────────────────────────────────────────────────────
|
|
295
|
+
// The machine-global terminal surface any permitted agent can drive over the
|
|
296
|
+
// relay: list EVERY live session (not just pags-* ones), read a pane, and —
|
|
297
|
+
// gated by write-consent in the cloud — send keys / run a command. Reuses the
|
|
298
|
+
// coding runtime's tmux primitives.
|
|
299
|
+
if ((req.method === "GET" || req.method === "POST") && path === "/tmux/list") {
|
|
300
|
+
const { listSessionsDetailed } = await import("./coding/tmux.js");
|
|
301
|
+
return json(res, 200, { sessions: listSessionsDetailed() });
|
|
302
|
+
}
|
|
303
|
+
if (req.method === "POST" && path === "/tmux/capture") {
|
|
304
|
+
const { capturePane, sessionExists } = await import("./coding/tmux.js");
|
|
305
|
+
const b = await readJson(req);
|
|
306
|
+
const session = String(b.session || "").trim();
|
|
307
|
+
if (!session)
|
|
308
|
+
return json(res, 400, { error: "A `session` name is required." });
|
|
309
|
+
if (!sessionExists(session))
|
|
310
|
+
return json(res, 404, { error: `No tmux session "${session}".` });
|
|
311
|
+
const lines = Math.min(Math.max(Number(b.lines) || 200, 1), 2000);
|
|
312
|
+
return json(res, 200, { session, pane: capturePane(session, lines) });
|
|
313
|
+
}
|
|
314
|
+
if (req.method === "POST" && path === "/tmux/send") {
|
|
315
|
+
const { sendText, sendKey, capturePane, sessionExists } = await import("./coding/tmux.js");
|
|
316
|
+
const b = await readJson(req);
|
|
317
|
+
const session = String(b.session || "").trim();
|
|
318
|
+
if (!session)
|
|
319
|
+
return json(res, 400, { error: "A `session` name is required." });
|
|
320
|
+
if (!sessionExists(session))
|
|
321
|
+
return json(res, 404, { error: `No tmux session "${session}".` });
|
|
322
|
+
if (b.text != null)
|
|
323
|
+
sendText(session, String(b.text));
|
|
324
|
+
for (const k of b.keys ?? [])
|
|
325
|
+
sendKey(session, String(k));
|
|
326
|
+
return json(res, 200, { session, pane: capturePane(session, 200) });
|
|
327
|
+
}
|
|
328
|
+
if (req.method === "POST" && path === "/tmux/run") {
|
|
329
|
+
const { runCommand, capturePane, sessionExists } = await import("./coding/tmux.js");
|
|
330
|
+
const b = await readJson(req);
|
|
331
|
+
const session = String(b.session || "").trim();
|
|
332
|
+
const command = String(b.command ?? "");
|
|
333
|
+
if (!session)
|
|
334
|
+
return json(res, 400, { error: "A `session` name is required." });
|
|
335
|
+
if (!command.trim())
|
|
336
|
+
return json(res, 400, { error: "A `command` is required." });
|
|
337
|
+
if (!sessionExists(session))
|
|
338
|
+
return json(res, 404, { error: `No tmux session "${session}".` });
|
|
339
|
+
runCommand(session, command);
|
|
340
|
+
return json(res, 200, { session, command, pane: capturePane(session, 200) });
|
|
341
|
+
}
|
|
342
|
+
if (req.method === "POST" && path === "/tmux/session") {
|
|
343
|
+
const { createSession, killSession, sessionExists } = await import("./coding/tmux.js");
|
|
344
|
+
const { homedir } = await import("node:os");
|
|
345
|
+
const b = await readJson(req);
|
|
346
|
+
const session = String(b.session || "").trim();
|
|
347
|
+
if (!session)
|
|
348
|
+
return json(res, 400, { error: "A `session` name is required." });
|
|
349
|
+
if (b.action === "kill") {
|
|
350
|
+
return json(res, 200, { session, killed: killSession(session) });
|
|
351
|
+
}
|
|
352
|
+
// default: create
|
|
353
|
+
if (sessionExists(session))
|
|
354
|
+
return json(res, 200, { session, created: false, existed: true });
|
|
355
|
+
const { resolve } = await import("node:path");
|
|
356
|
+
const workDir = resolve(String(b.workDir || "~").replace(/^~(?=$|\/)/, homedir()));
|
|
357
|
+
createSession(session, workDir, b.command ? String(b.command) : undefined);
|
|
358
|
+
return json(res, 200, { session, created: true, workDir });
|
|
359
|
+
}
|
|
285
360
|
return json(res, 404, { error: "Not found" });
|
|
286
361
|
}
|
|
287
362
|
function authorize(req, config) {
|