@proagentstore/cli 0.4.14 → 0.4.16
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.
|
@@ -63,16 +63,21 @@ export class HeadlessSession {
|
|
|
63
63
|
// that keeps refreshing lastOutputAt). The user can also Restart from diagnostics.
|
|
64
64
|
// Rule 1 deliberately waits for first output so a slow first token can't flip us
|
|
65
65
|
// to idle mid-turn (which would make the brain proceed against a half-done turn).
|
|
66
|
+
//
|
|
67
|
+
// This is derived PURELY from timers each call — it does NOT latch `this.run` to
|
|
68
|
+
// "idle". Latching was a one-way trap: once a >1.5s pause (slow compile / test run /
|
|
69
|
+
// network) flipped us idle, resumed output couldn't restore "thinking" (onStdout only
|
|
70
|
+
// bumps lastOutputAt), so the brain proceeded against a still-running turn and
|
|
71
|
+
// double-sent or falsely called done. Recomputing means fresh output → quiet resets →
|
|
72
|
+
// "thinking" again, all on its own.
|
|
66
73
|
if (this.mode === "raw" && this.run === "thinking") {
|
|
67
74
|
const now = Date.now();
|
|
68
75
|
const quiet = now - this.lastOutputAt;
|
|
69
76
|
const elapsed = now - this.turnStartedAt;
|
|
70
|
-
|
|
71
|
-
this.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
else if (elapsed > 15 * 60 * 1000)
|
|
75
|
-
this.run = "idle";
|
|
77
|
+
const settled = (this.sawOutputSinceInput && quiet > 1500) ||
|
|
78
|
+
(!this.sawOutputSinceInput && elapsed > 8000) ||
|
|
79
|
+
elapsed > 15 * 60 * 1000;
|
|
80
|
+
return settled ? "idle" : "thinking";
|
|
76
81
|
}
|
|
77
82
|
return this.run === "thinking" ? "thinking" : "idle";
|
|
78
83
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { relative, resolve, sep } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Read-only code inspection for the coding runtime — the "eyes" the Co-pilot/Chat use
|
|
6
|
+
* to GROUND their answers in the real repo (read a file, `git diff`, list the tree)
|
|
7
|
+
* WITHOUT driving the live CLI. All access is confined to the session's workDir.
|
|
8
|
+
*
|
|
9
|
+
* Two pure, separately-tested primitives carry the safety:
|
|
10
|
+
* - resolveInside(): rejects any path escaping the repo root (../, absolute, sibling
|
|
11
|
+
* prefix, symlink escape).
|
|
12
|
+
* - gitArgv(): maps a fixed command enum to a fixed argv — no user string ever
|
|
13
|
+
* becomes a git token except a resolveInside-validated path after a literal `--`.
|
|
14
|
+
*/
|
|
15
|
+
/** Resolve `rel` under `root`, refusing anything that escapes it. Pure (no fs) EXCEPT the
|
|
16
|
+
* optional symlink check, which is what defends against a symlink inside the repo pointing
|
|
17
|
+
* at e.g. ~/.ssh. Throws on any escape. */
|
|
18
|
+
export function resolveInside(root, rel, opts = {}) {
|
|
19
|
+
const rootAbs = resolve(root);
|
|
20
|
+
const abs = resolve(rootAbs, rel);
|
|
21
|
+
// `resolve` collapses `..`, so a traversal or absolute escape lands outside rootAbs.
|
|
22
|
+
// The explicit `+ sep` blocks a sibling-prefix attack (/repo vs /repo-secrets).
|
|
23
|
+
if (abs !== rootAbs && !abs.startsWith(rootAbs + sep)) {
|
|
24
|
+
throw new InspectError(`path escapes the repo: ${rel}`);
|
|
25
|
+
}
|
|
26
|
+
if (opts.checkSymlink && existsSync(abs)) {
|
|
27
|
+
// A symlink inside the repo could still point outside it — resolve the real path and
|
|
28
|
+
// re-check the same invariant.
|
|
29
|
+
const real = realpathSync(abs);
|
|
30
|
+
const realRoot = realpathSync(rootAbs);
|
|
31
|
+
if (real !== realRoot && !real.startsWith(realRoot + sep)) {
|
|
32
|
+
throw new InspectError(`path resolves (via symlink) outside the repo: ${rel}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return abs;
|
|
36
|
+
}
|
|
37
|
+
/** Map a whitelisted command enum to a fixed git argv. `path` (already validated by the
|
|
38
|
+
* caller via resolveInside) is only ever appended after a literal `--` separator. */
|
|
39
|
+
export function gitArgv(cmd, opts = {}) {
|
|
40
|
+
const clampN = Math.max(1, Math.min(200, Math.floor(opts.n ?? 20)));
|
|
41
|
+
switch (cmd) {
|
|
42
|
+
case "status":
|
|
43
|
+
return ["status", "--short"];
|
|
44
|
+
case "diff":
|
|
45
|
+
return opts.relPath ? ["diff", "--", opts.relPath] : ["diff"];
|
|
46
|
+
case "diff-stat":
|
|
47
|
+
return ["diff", "--stat"];
|
|
48
|
+
case "log":
|
|
49
|
+
return ["log", "--oneline", "-n", String(clampN)];
|
|
50
|
+
case "ls-files":
|
|
51
|
+
return ["ls-files"];
|
|
52
|
+
default:
|
|
53
|
+
throw new InspectError(`unsupported git command: ${cmd}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export class InspectError extends Error {
|
|
57
|
+
constructor(msg) {
|
|
58
|
+
super(msg);
|
|
59
|
+
this.name = "InspectError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const DEFAULT_MAX_FILE_BYTES = 64 * 1024;
|
|
63
|
+
const HARD_MAX_FILE_BYTES = 128 * 1024;
|
|
64
|
+
/** Read a text file inside the repo. Rejects traversal, oversize, and binary files. */
|
|
65
|
+
export function readRepoFile(workDir, relPath, maxBytes) {
|
|
66
|
+
const abs = resolveInside(workDir, relPath, { checkSymlink: true });
|
|
67
|
+
const st = statSync(abs);
|
|
68
|
+
if (!st.isFile())
|
|
69
|
+
throw new InspectError(`not a regular file: ${relPath}`);
|
|
70
|
+
const cap = Math.min(maxBytes ?? DEFAULT_MAX_FILE_BYTES, HARD_MAX_FILE_BYTES);
|
|
71
|
+
const buf = readFileSync(abs);
|
|
72
|
+
// Binary sniff: a NUL byte in the first 8KB → don't feed bytes to the model.
|
|
73
|
+
const head = buf.subarray(0, 8192);
|
|
74
|
+
if (head.includes(0))
|
|
75
|
+
return { path: relPath, size: st.size, truncated: false, binary: true };
|
|
76
|
+
const truncated = buf.length > cap;
|
|
77
|
+
return { path: relPath, size: st.size, truncated, content: buf.subarray(0, cap).toString("utf-8") };
|
|
78
|
+
}
|
|
79
|
+
/** Run a whitelisted read-only git command in the repo. Never uses a shell. */
|
|
80
|
+
export function runRepoGit(workDir, cmd, opts = {}) {
|
|
81
|
+
if (!existsSync(resolve(workDir, ".git")))
|
|
82
|
+
throw new InspectError("not a git repo");
|
|
83
|
+
const relPath = opts.path ? relative(workDir, resolveInside(workDir, opts.path)) : undefined;
|
|
84
|
+
const argv = gitArgv(cmd, { relPath, n: opts.n });
|
|
85
|
+
let out = "";
|
|
86
|
+
try {
|
|
87
|
+
out = execFileSync("git", argv, { cwd: workDir, encoding: "utf-8", timeout: 10_000, maxBuffer: 4 * 1024 * 1024 });
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
// git exits non-zero for benign cases (e.g. `diff` on nothing) — surface stdout if present.
|
|
91
|
+
const err = e;
|
|
92
|
+
out = err.stdout ?? "";
|
|
93
|
+
if (!out)
|
|
94
|
+
throw new InspectError(err.message || `git ${cmd} failed`);
|
|
95
|
+
}
|
|
96
|
+
const cap = opts.maxBytes ?? 64 * 1024;
|
|
97
|
+
const truncated = out.length > cap;
|
|
98
|
+
return { cmd, output: truncated ? out.slice(0, cap) : out, truncated };
|
|
99
|
+
}
|
|
100
|
+
const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", ".turbo", "coverage", ".wrangler"]);
|
|
101
|
+
/** Bounded recursive file tree (names/type/size only — no contents). */
|
|
102
|
+
export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500) {
|
|
103
|
+
const start = resolveInside(workDir, relPath, { checkSymlink: true });
|
|
104
|
+
const depthCap = Math.max(1, Math.min(4, maxDepth));
|
|
105
|
+
const entryCap = Math.max(1, Math.min(1000, maxEntries));
|
|
106
|
+
const entries = [];
|
|
107
|
+
const queue = [{ dir: start, depth: 0 }];
|
|
108
|
+
let truncated = false;
|
|
109
|
+
while (queue.length) {
|
|
110
|
+
const { dir, depth } = queue.shift();
|
|
111
|
+
let items;
|
|
112
|
+
try {
|
|
113
|
+
items = readdirSync(dir, { withFileTypes: true });
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
for (const it of items) {
|
|
119
|
+
if (it.name.startsWith(".") || IGNORE_DIRS.has(it.name))
|
|
120
|
+
continue;
|
|
121
|
+
if (entries.length >= entryCap) {
|
|
122
|
+
truncated = true;
|
|
123
|
+
return { root: relPath, entries, truncated };
|
|
124
|
+
}
|
|
125
|
+
const abs = resolve(dir, it.name);
|
|
126
|
+
const rel = relative(workDir, abs);
|
|
127
|
+
if (it.isDirectory()) {
|
|
128
|
+
entries.push({ path: rel, type: "dir" });
|
|
129
|
+
if (depth + 1 < depthCap)
|
|
130
|
+
queue.push({ dir: abs, depth: depth + 1 });
|
|
131
|
+
}
|
|
132
|
+
else if (it.isFile()) {
|
|
133
|
+
let size;
|
|
134
|
+
try {
|
|
135
|
+
size = statSync(abs).size;
|
|
136
|
+
}
|
|
137
|
+
catch { }
|
|
138
|
+
entries.push({ path: rel, type: "file", size });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { root: relPath, entries, truncated };
|
|
143
|
+
}
|
|
@@ -1,6 +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
5
|
import { ensureRepo, sanitizeSessionName } from "./tmux.js";
|
|
5
6
|
/** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
|
|
6
7
|
const MAX_PANE = 64 * 1024;
|
|
@@ -18,9 +19,39 @@ export class CodingRuntime {
|
|
|
18
19
|
constructor(reposBaseDir = join(homedir(), ".config", "proagentstore", "repos")) {
|
|
19
20
|
this.reposBaseDir = reposBaseDir;
|
|
20
21
|
}
|
|
21
|
-
/** Capabilities advertised to PAGS at registration.
|
|
22
|
+
/** Capabilities advertised to PAGS at registration. `coding.inspect` signals the
|
|
23
|
+
* read-only code-inspection endpoints exist, so the cloud offers the grounding tools
|
|
24
|
+
* (older runners omit it → the cloud degrades to terminal-only). */
|
|
22
25
|
static capabilities() {
|
|
23
|
-
return ["coding.sessions", "coding.stream", "human.takeover"];
|
|
26
|
+
return ["coding.sessions", "coding.stream", "human.takeover", "coding.inspect"];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the workDir for a read-only inspection. Prefer the tracked session's real
|
|
30
|
+
* workDir (authoritative even for managed clone dirs); fall back to an explicit path
|
|
31
|
+
* (the cloud passes the D1 `repo.workdir` when the session map is empty after a runner
|
|
32
|
+
* restart). Expands a leading `~` the same way start() does.
|
|
33
|
+
*/
|
|
34
|
+
resolveWorkDir(input) {
|
|
35
|
+
if (input.sessionId) {
|
|
36
|
+
const s = this.sessions.get(input.sessionId);
|
|
37
|
+
if (s)
|
|
38
|
+
return s.config.workDir;
|
|
39
|
+
}
|
|
40
|
+
if (input.workDir)
|
|
41
|
+
return resolve(input.workDir.replace(/^~(?=$|\/)/, homedir()));
|
|
42
|
+
throw new InspectError("no session or workDir to inspect");
|
|
43
|
+
}
|
|
44
|
+
/** Read one file inside the session's repo (traversal-guarded, size-capped). */
|
|
45
|
+
readFile(input) {
|
|
46
|
+
return readRepoFile(this.resolveWorkDir(input), input.path, input.maxBytes);
|
|
47
|
+
}
|
|
48
|
+
/** Run a whitelisted read-only git command in the session's repo. */
|
|
49
|
+
git(input) {
|
|
50
|
+
return runRepoGit(this.resolveWorkDir(input), input.cmd, { path: input.path, n: input.n });
|
|
51
|
+
}
|
|
52
|
+
/** Bounded recursive file tree of the session's repo (names/type/size only). */
|
|
53
|
+
tree(input) {
|
|
54
|
+
return repoTree(this.resolveWorkDir(input), input.path, input.maxDepth, input.maxEntries);
|
|
24
55
|
}
|
|
25
56
|
static taskTypes() {
|
|
26
57
|
return ["coding.session"];
|
|
@@ -1045,6 +1045,13 @@ export class LocalRunner {
|
|
|
1045
1045
|
// "running" — report failure so the console can say "session expired, re-run".
|
|
1046
1046
|
if (!session)
|
|
1047
1047
|
return { ok: false };
|
|
1048
|
+
// Reject an empty value: browserHandoffStatus reports needs_input as solved only when
|
|
1049
|
+
// inputValue is a real string, so accepting "" would flip the task to "running" yet
|
|
1050
|
+
// never resolve → the workflow polls until it times out (15-min wedge, "failed").
|
|
1051
|
+
// Report failure so the caller (API/MCP) learns it wasn't accepted, like the
|
|
1052
|
+
// no-session branch. (The console already blocks empty; this closes the API path.)
|
|
1053
|
+
if (!value || !value.trim())
|
|
1054
|
+
return { ok: false };
|
|
1048
1055
|
session.inputValue = value;
|
|
1049
1056
|
const task = this.store.getTask(taskId);
|
|
1050
1057
|
if (task) {
|
|
@@ -222,6 +222,35 @@ async function route(runner, req, res) {
|
|
|
222
222
|
}
|
|
223
223
|
return json(res, 200, { killed, sessions: targets });
|
|
224
224
|
}
|
|
225
|
+
// ── Read-only code inspection (the Co-pilot/Chat's "eyes" — no CLI driving) ──
|
|
226
|
+
// Confined to the session's workDir by inspect.ts; errors surface as 400.
|
|
227
|
+
if (req.method === "POST" && path === "/coding/read-file") {
|
|
228
|
+
const b = await readJson(req);
|
|
229
|
+
try {
|
|
230
|
+
return json(res, 200, runner.coding.readFile(b));
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (req.method === "POST" && path === "/coding/git") {
|
|
237
|
+
const b = await readJson(req);
|
|
238
|
+
try {
|
|
239
|
+
return json(res, 200, runner.coding.git(b));
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (req.method === "POST" && path === "/coding/tree") {
|
|
246
|
+
const b = await readJson(req);
|
|
247
|
+
try {
|
|
248
|
+
return json(res, 200, runner.coding.tree(b));
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
225
254
|
if (req.method === "POST" && path === "/coding/event") {
|
|
226
255
|
// Brain progress events — recorded by PAGS, ignored locally. Accept + ack.
|
|
227
256
|
return json(res, 200, { ok: true });
|