@proagentstore/cli 0.4.48 → 0.4.50
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.
|
@@ -44,15 +44,29 @@ export function gitArgv(cmd, opts = {}) {
|
|
|
44
44
|
// caller can learn a tree is dirty but never which branch it is dirty on, which is how
|
|
45
45
|
// a delegated run pushed a PR branch and left the checkout parked there unnoticed
|
|
46
46
|
// (#276). Additive: every existing consumer keeps the same file lines it always got.
|
|
47
|
-
|
|
47
|
+
//
|
|
48
|
+
// `path` reaches this one too (#508). Narrowing every command rather than four of the
|
|
49
|
+
// five is what lets the tool description say "it applies" with no caveat — and a
|
|
50
|
+
// caveat is what a model has to reason about and can get wrong.
|
|
51
|
+
return opts.relPath ? ["status", "--short", "--branch", "--", opts.relPath] : ["status", "--short", "--branch"];
|
|
52
|
+
// `path` used to reach exactly ONE of these five (#508). It is advertised on the tool as
|
|
53
|
+
// "Limit the command to one file or folder", `runRepoGit` resolves and validates it, and
|
|
54
|
+
// then four of the five branches dropped it on the floor — so
|
|
55
|
+
// `repo_git {cmd:"ls-files", path:"admin/lib/features/events"}` answered with every tracked
|
|
56
|
+
// file in the repository, truncated mid-list at 12KB. `git ls-files -- <path>` is the
|
|
57
|
+
// file-finder a Repo Coder has been missing, and the parameter to reach it was already in
|
|
58
|
+
// the schema and already validated; it was dropped one function later.
|
|
59
|
+
//
|
|
60
|
+
// Every one of these is git's own `--` pathspec discipline, unchanged: the validated path
|
|
61
|
+
// is appended after a literal separator and can never be read as a flag or a revision.
|
|
48
62
|
case "diff":
|
|
49
63
|
return opts.relPath ? ["diff", "--", opts.relPath] : ["diff"];
|
|
50
64
|
case "diff-stat":
|
|
51
|
-
return ["diff", "--stat"];
|
|
65
|
+
return opts.relPath ? ["diff", "--stat", "--", opts.relPath] : ["diff", "--stat"];
|
|
52
66
|
case "log":
|
|
53
|
-
return ["log", "--oneline", "-n", String(clampN)];
|
|
67
|
+
return opts.relPath ? ["log", "--oneline", "-n", String(clampN), "--", opts.relPath] : ["log", "--oneline", "-n", String(clampN)];
|
|
54
68
|
case "ls-files":
|
|
55
|
-
return ["ls-files"];
|
|
69
|
+
return opts.relPath ? ["ls-files", "--", opts.relPath] : ["ls-files"];
|
|
56
70
|
default:
|
|
57
71
|
throw new InspectError(`unsupported git command: ${cmd}`);
|
|
58
72
|
}
|
|
@@ -86,6 +100,12 @@ export function runRepoGit(workDir, cmd, opts = {}) {
|
|
|
86
100
|
throw new InspectError("not a git repo");
|
|
87
101
|
const relPath = opts.path ? relative(workDir, resolveInside(workDir, opts.path)) : undefined;
|
|
88
102
|
const argv = gitArgv(cmd, { relPath, n: opts.n });
|
|
103
|
+
// Did the path the caller asked for actually reach git? Reported rather than assumed, because
|
|
104
|
+
// a runner is a SEPARATE release from the cloud that calls it: before #508 four of the five
|
|
105
|
+
// commands ignored `path` silently, and the answer — the whole repository — was indistinguishable
|
|
106
|
+
// from a correct one. An older runner omits this field entirely, which is what lets the cloud
|
|
107
|
+
// say "your machine ignored the filter" instead of relaying a wrong answer as a right one.
|
|
108
|
+
const pathApplied = relPath !== undefined && argv.includes(relPath);
|
|
89
109
|
let out = "";
|
|
90
110
|
try {
|
|
91
111
|
out = execFileSync("git", argv, { cwd: workDir, encoding: "utf-8", timeout: 10_000, maxBuffer: 4 * 1024 * 1024 });
|
|
@@ -99,7 +119,7 @@ export function runRepoGit(workDir, cmd, opts = {}) {
|
|
|
99
119
|
}
|
|
100
120
|
const cap = opts.maxBytes ?? 64 * 1024;
|
|
101
121
|
const truncated = out.length > cap;
|
|
102
|
-
return { cmd, output: truncated ? out.slice(0, cap) : out, truncated };
|
|
122
|
+
return { cmd, output: truncated ? out.slice(0, cap) : out, truncated, pathApplied };
|
|
103
123
|
}
|
|
104
124
|
/** Read the repo's `origin` remote URL — used to auto-associate a local checkout with its
|
|
105
125
|
* GitHub repo (so build status can query Actions). Fixed argv, no shell, no user input;
|
|
@@ -120,14 +140,125 @@ export function readGitRemoteOrigin(workDir) {
|
|
|
120
140
|
}
|
|
121
141
|
}
|
|
122
142
|
const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", ".turbo", "coverage", ".wrangler"]);
|
|
123
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* Search the repo — the capability a Repo Coder never had (#508).
|
|
145
|
+
*
|
|
146
|
+
* Before this there was no grep, no filename match, no content match anywhere in the connector or
|
|
147
|
+
* the runner, so locating a file meant walking the tree by hand and guessing when it ran out.
|
|
148
|
+
*
|
|
149
|
+
* ── The bounds, and why each one is here rather than "cap the bytes at the end"
|
|
150
|
+
*
|
|
151
|
+
* A byte cap alone just moves the problem into the tool-result cap (#503): the model receives an
|
|
152
|
+
* arbitrary prefix of a list and no statement that a list was cut. Every bound below is COUNTED,
|
|
153
|
+
* so the result can say `50 of 812` and the model knows to narrow rather than conclude.
|
|
154
|
+
*
|
|
155
|
+
* MAX_RESULTS the whole answer, not per file — one number the caller can reason about.
|
|
156
|
+
* PER_FILE git's own `--max-count`, so a minified bundle cannot spend the whole budget.
|
|
157
|
+
* MAX_LINE a matching line from a generated file can be tens of KB on its own.
|
|
158
|
+
* maxBuffer git's stdout is read into memory before any of the above can apply.
|
|
159
|
+
*
|
|
160
|
+
* Worst case is ~50 × (path + 160) ≈ 11KB, comfortably inside the 12KB the connector allows and
|
|
161
|
+
* the 24,000 chars a tool result may carry.
|
|
162
|
+
*
|
|
163
|
+
* ── Fixed strings, not regexes
|
|
164
|
+
*
|
|
165
|
+
* `-F`. The job is "where does this identifier / filename appear", which is what a model supplies
|
|
166
|
+
* naturally, and a bare `foo(` — the obvious thing to search for in code — is an invalid regex.
|
|
167
|
+
* It also means no pattern the model invents can become a pathological match on the owner's own
|
|
168
|
+
* machine. Deliberate deviation from the argv sketched in #508, which used `-e` alone.
|
|
169
|
+
*
|
|
170
|
+
* ── The safety property is unchanged
|
|
171
|
+
*
|
|
172
|
+
* `path` is still resolveInside-validated and still appended after a literal `--`. The PATTERN is
|
|
173
|
+
* never a git token in `path` mode (the filter runs here, in JS) and in `content` mode it is the
|
|
174
|
+
* fixed operand of `-e`, which git cannot read as a flag whatever it contains.
|
|
175
|
+
*/
|
|
176
|
+
export const SEARCH_MAX_RESULTS = 50;
|
|
177
|
+
const SEARCH_PER_FILE = 5;
|
|
178
|
+
const SEARCH_MAX_LINE = 160;
|
|
179
|
+
const SEARCH_MAX_PATTERN = 200;
|
|
180
|
+
export function repoSearch(workDir, opts) {
|
|
181
|
+
if (!existsSync(resolve(workDir, ".git")))
|
|
182
|
+
throw new InspectError("not a git repo");
|
|
183
|
+
const pattern = (opts.pattern ?? "").trim();
|
|
184
|
+
if (!pattern)
|
|
185
|
+
throw new InspectError("a search `pattern` is required");
|
|
186
|
+
if (pattern.length > SEARCH_MAX_PATTERN)
|
|
187
|
+
throw new InspectError(`search pattern is too long (max ${SEARCH_MAX_PATTERN} characters)`);
|
|
188
|
+
const mode = opts.mode === "path" ? "path" : "content";
|
|
189
|
+
const limit = Math.max(1, Math.min(SEARCH_MAX_RESULTS, Math.floor(opts.maxResults ?? SEARCH_MAX_RESULTS)));
|
|
190
|
+
const relPath = opts.path ? relative(workDir, resolveInside(workDir, opts.path)) : undefined;
|
|
191
|
+
const argv = mode === "content"
|
|
192
|
+
? ["grep", "-n", "-I", "-i", "-F", "--untracked", "--max-count", String(SEARCH_PER_FILE), "-e", pattern]
|
|
193
|
+
: // Tracked PLUS untracked-not-ignored: a file created ten minutes ago is exactly the one
|
|
194
|
+
// somebody is trying to find, and `ls-files` alone would deny it exists.
|
|
195
|
+
["ls-files", "--cached", "--others", "--exclude-standard"];
|
|
196
|
+
// `.` rather than nothing: `git grep` run from a subdirectory would otherwise search the whole
|
|
197
|
+
// tree, and the caller asked for a folder.
|
|
198
|
+
if (relPath && relPath !== "")
|
|
199
|
+
argv.push("--", relPath);
|
|
200
|
+
let out;
|
|
201
|
+
try {
|
|
202
|
+
out = execFileSync("git", argv, { cwd: workDir, encoding: "utf-8", timeout: 15_000, maxBuffer: 4 * 1024 * 1024 });
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
const err = e;
|
|
206
|
+
// `git grep` exits 1 for "no matches", which is an ANSWER, not a failure.
|
|
207
|
+
if (err.status === 1)
|
|
208
|
+
out = err.stdout ?? "";
|
|
209
|
+
else if (err.code === "ENOBUFS")
|
|
210
|
+
throw new InspectError(`too many matches for "${pattern}" to read safely — search a narrower path, or a longer/more specific pattern`);
|
|
211
|
+
else if (err.stdout)
|
|
212
|
+
out = err.stdout;
|
|
213
|
+
else
|
|
214
|
+
throw new InspectError(err.message || `git ${mode} search failed`);
|
|
215
|
+
}
|
|
216
|
+
const lines = out.split("\n").filter((l) => l !== "");
|
|
217
|
+
const all = mode === "content"
|
|
218
|
+
? lines.map(parseGrepLine).filter((m) => m !== null)
|
|
219
|
+
: // The pattern never reaches git here — the filter is a plain case-insensitive substring
|
|
220
|
+
// over the path, which is what "find the file called X" actually means.
|
|
221
|
+
lines.filter((p) => p.toLowerCase().includes(pattern.toLowerCase())).map((p) => ({ path: p }));
|
|
222
|
+
return { mode, pattern, matches: all.slice(0, limit), shown: Math.min(all.length, limit), total: all.length, truncated: all.length > limit };
|
|
223
|
+
}
|
|
224
|
+
/** `path:line:text` — split on the FIRST two colons only, since code is full of them. */
|
|
225
|
+
function parseGrepLine(line) {
|
|
226
|
+
const first = line.indexOf(":");
|
|
227
|
+
if (first <= 0)
|
|
228
|
+
return null;
|
|
229
|
+
const second = line.indexOf(":", first + 1);
|
|
230
|
+
if (second < 0)
|
|
231
|
+
return null;
|
|
232
|
+
const n = Number.parseInt(line.slice(first + 1, second), 10);
|
|
233
|
+
if (!Number.isFinite(n))
|
|
234
|
+
return null;
|
|
235
|
+
return { path: line.slice(0, first), line: n, text: line.slice(second + 1).trim().slice(0, SEARCH_MAX_LINE) };
|
|
236
|
+
}
|
|
237
|
+
/** The deepest `maxDepth` this will honour. Named because the TOOL has to say it (#508). */
|
|
238
|
+
export const TREE_MAX_DEPTH = 4;
|
|
239
|
+
/**
|
|
240
|
+
* Bounded recursive file tree (names/type/size only — no contents).
|
|
241
|
+
*
|
|
242
|
+
* Two caps stop it, and until #508 only ONE of them was reported. `truncated` was set by the
|
|
243
|
+
* ENTRY cap alone; a directory sitting at the depth boundary was emitted as an entry and its
|
|
244
|
+
* children simply not queued, so it rendered exactly like a directory with nothing in it.
|
|
245
|
+
*
|
|
246
|
+
* From the model's side "this folder is empty" and "this folder is deeper than I am allowed to
|
|
247
|
+
* look" were the same observation, and it cannot navigate on that: asked about a file seven
|
|
248
|
+
* segments down, it saw a leaf, decided the leaf WAS the file, and called `repo_read_file` on a
|
|
249
|
+
* directory — twice in the turn that produced this ticket. `deeper` is the per-entry fact that
|
|
250
|
+
* removes the ambiguity, and it is deliberately "contents not listed" rather than "has more
|
|
251
|
+
* inside": checking the latter costs a readdir per boundary directory, and the model's next move
|
|
252
|
+
* (call again with `path`) is the same either way.
|
|
253
|
+
*/
|
|
124
254
|
export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500) {
|
|
125
255
|
const start = resolveInside(workDir, relPath, { checkSymlink: true });
|
|
126
|
-
const depthCap = Math.max(1, Math.min(
|
|
256
|
+
const depthCap = Math.max(1, Math.min(TREE_MAX_DEPTH, maxDepth));
|
|
127
257
|
const entryCap = Math.max(1, Math.min(1000, maxEntries));
|
|
128
258
|
const entries = [];
|
|
129
259
|
const queue = [{ dir: start, depth: 0 }];
|
|
130
260
|
let truncated = false;
|
|
261
|
+
let truncatedByDepth = false;
|
|
131
262
|
while (queue.length) {
|
|
132
263
|
const { dir, depth } = queue.shift();
|
|
133
264
|
let items;
|
|
@@ -142,24 +273,36 @@ export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500)
|
|
|
142
273
|
continue;
|
|
143
274
|
if (entries.length >= entryCap) {
|
|
144
275
|
truncated = true;
|
|
145
|
-
return { root: relPath, entries, truncated };
|
|
276
|
+
return { root: relPath, entries, truncated, truncatedByDepth, depthCap };
|
|
146
277
|
}
|
|
147
278
|
const abs = resolve(dir, it.name);
|
|
148
279
|
const rel = relative(workDir, abs);
|
|
149
280
|
if (it.isDirectory()) {
|
|
150
|
-
|
|
151
|
-
|
|
281
|
+
const walk = depth + 1 < depthCap;
|
|
282
|
+
entries.push(walk ? { path: rel, type: "dir" } : { path: rel, type: "dir", deeper: true });
|
|
283
|
+
if (walk)
|
|
152
284
|
queue.push({ dir: abs, depth: depth + 1 });
|
|
285
|
+
else
|
|
286
|
+
truncatedByDepth = true;
|
|
153
287
|
}
|
|
154
288
|
else if (it.isFile()) {
|
|
155
289
|
let size;
|
|
156
290
|
try {
|
|
157
291
|
size = statSync(abs).size;
|
|
158
292
|
}
|
|
159
|
-
catch {
|
|
293
|
+
catch {
|
|
294
|
+
// Benign, and traced rather than assumed (#291). `size` stays `undefined`, which JSON
|
|
295
|
+
// drops, so the entry reaches the model as `{path, type: "file"}` — the same listing a
|
|
296
|
+
// successful stat produces, because `repo-local.ts`'s renderer prints `e.path` and has
|
|
297
|
+
// never shown a size at all. The test is whether the fallback can be mistaken for a
|
|
298
|
+
// real answer: `size: 0` would be a claim about an empty file; an absent field is not
|
|
299
|
+
// a claim. The entry itself is still emitted because `readdir` saw it, and the one
|
|
300
|
+
// case where that is already stale — deleted between the readdir and this stat —
|
|
301
|
+
// degrades into an honest error from `repo_read_file`, not into a wrong listing.
|
|
302
|
+
}
|
|
160
303
|
entries.push({ path: rel, type: "file", size });
|
|
161
304
|
}
|
|
162
305
|
}
|
|
163
306
|
}
|
|
164
|
-
return { root: relPath, entries, truncated };
|
|
307
|
+
return { root: relPath, entries, truncated, truncatedByDepth, depthCap };
|
|
165
308
|
}
|
|
@@ -119,10 +119,18 @@ export function switchRepoBranch(workDir, branch) {
|
|
|
119
119
|
// CONFIRM, do not assume. The exit code says the command ran; only reading HEAD back says where
|
|
120
120
|
// the checkout actually is, and that is the only thing the cloud is allowed to report as done.
|
|
121
121
|
const after = currentBranch(workDir);
|
|
122
|
-
|
|
122
|
+
// And the same rule for the tree: `null` when git would not answer, never `false`. Nothing in
|
|
123
|
+
// the cloud reads this field today — `repo-policy-act.ts` acts on `refused`, `error` and its own
|
|
124
|
+
// independent read — so this is prophylactic rather than a live defect, and it is recorded that
|
|
125
|
+
// way. What makes it worth changing anyway is that the value is a CLAIM and the next reader
|
|
126
|
+
// inherits it: `dirty: false` off a failed `git status` says "clean" in the one field whose
|
|
127
|
+
// whole job is to say whether anything came across. Absent is degraded; manufactured is wrong.
|
|
128
|
+
let dirtyAfter;
|
|
123
129
|
try {
|
|
124
130
|
dirtyAfter = isDirty(workDir);
|
|
125
131
|
}
|
|
126
|
-
catch {
|
|
132
|
+
catch {
|
|
133
|
+
dirtyAfter = null;
|
|
134
|
+
}
|
|
127
135
|
return { ok: after === to, changed: after === to, from, to, branch: after, dirty: dirtyAfter };
|
|
128
136
|
}
|
|
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { RunnerInputError } from "../errors.js";
|
|
4
4
|
import { defaultStatePath, HeadlessSession } from "./headless.js";
|
|
5
|
-
import { InspectError, readGitRemoteOrigin, readRepoFile, repoTree, runRepoGit } from "./inspect.js";
|
|
5
|
+
import { InspectError, readGitRemoteOrigin, readRepoFile, repoSearch, repoTree, runRepoGit } from "./inspect.js";
|
|
6
6
|
import { switchRepoBranch } from "./repo-write.js";
|
|
7
7
|
import { checkWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
8
8
|
/** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
|
|
@@ -65,6 +65,16 @@ export class CodingRuntime {
|
|
|
65
65
|
throw new InspectError(`unsupported git write command: ${String(input.cmd)}`);
|
|
66
66
|
return switchRepoBranch(this.resolveWorkDir(input), input.branch);
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Find something in the repo — by file CONTENT or by file NAME (#508).
|
|
70
|
+
*
|
|
71
|
+
* The one capability the read-only inspection surface never had. Without it, locating a file
|
|
72
|
+
* meant walking `tree` by hand, and a path deeper than the tree's four-level cap was not
|
|
73
|
+
* reachable in any number of calls that did not already know the answer.
|
|
74
|
+
*/
|
|
75
|
+
search(input) {
|
|
76
|
+
return repoSearch(this.resolveWorkDir(input), input);
|
|
77
|
+
}
|
|
68
78
|
/** Bounded recursive file tree of the session's repo (names/type/size only). */
|
|
69
79
|
tree(input) {
|
|
70
80
|
return repoTree(this.resolveWorkDir(input), input.path, input.maxDepth, input.maxEntries);
|
|
@@ -177,6 +187,17 @@ export class CodingRuntime {
|
|
|
177
187
|
* turn of a session very often runs after the final capture poll, and ending is where that
|
|
178
188
|
* record would otherwise be lost — silently, and only for the turns at the end of every
|
|
179
189
|
* session, which is a bias rather than noise.
|
|
190
|
+
*
|
|
191
|
+
* …and, since #554, WHO PAID for it. The spend was already returned here; the observation that
|
|
192
|
+
* makes it attributable was one field away on the session object in hand, so every closing turn
|
|
193
|
+
* of every session reached the ledger with `payer` NULL even when the credential was known. The
|
|
194
|
+
* bias is the same one the paragraph above describes, which is why the omission mattered: it
|
|
195
|
+
* did not lose a random sample of turns, it lost the last turn of every session.
|
|
196
|
+
*
|
|
197
|
+
* `null` is a REAL answer here, not a default. `end()` tolerates a `sessionId` it has never
|
|
198
|
+
* heard of, and the honest report for a session this runner does not have is that it cannot say
|
|
199
|
+
* what the engine authenticated with — not a guess derived from the preset (see
|
|
200
|
+
* `usage-payer.ts`, and the alternative #554 rejected).
|
|
180
201
|
*/
|
|
181
202
|
end(sessionId) {
|
|
182
203
|
const session = this.sessions.get(sessionId);
|
|
@@ -186,12 +207,16 @@ export class CodingRuntime {
|
|
|
186
207
|
// happens after the final capture poll. Discarding the tail would systematically lose exactly
|
|
187
208
|
// the acts this record exists for.
|
|
188
209
|
const acts = session ? session.takeActs() : [];
|
|
210
|
+
// Read BEFORE `stop()`: `authResolved` is a live getter over the merged spawn env
|
|
211
|
+
// (`headless.ts`), so it must be taken while the session is still the object that spawned
|
|
212
|
+
// the process rather than after it has been torn down and dropped from the map.
|
|
213
|
+
const authResolved = session ? session.authResolved : null;
|
|
189
214
|
if (session) {
|
|
190
215
|
session.stop();
|
|
191
216
|
this.sessions.delete(sessionId);
|
|
192
217
|
}
|
|
193
218
|
this.takeovers.delete(sessionId);
|
|
194
|
-
return { ok: true, usage, acts };
|
|
219
|
+
return { ok: true, usage, acts, authResolved };
|
|
195
220
|
}
|
|
196
221
|
list() {
|
|
197
222
|
return [...this.sessions.entries()].map(([sessionId, s]) => ({
|
|
@@ -214,6 +214,19 @@ async function route(runner, req, res) {
|
|
|
214
214
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
|
+
// Find a file by content or by name (#508). A SEPARATE endpoint rather than another `/coding/git`
|
|
218
|
+
// command on purpose: an older runner answers an unknown path with a 404, which the cloud reads
|
|
219
|
+
// unambiguously as "this machine is too old" — an unknown `cmd` would arrive as a 400 that reads
|
|
220
|
+
// identically to a real search failure.
|
|
221
|
+
if (req.method === "POST" && path === "/coding/search") {
|
|
222
|
+
const b = await readJson(req);
|
|
223
|
+
try {
|
|
224
|
+
return json(res, 200, runner.coding.search(b));
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
217
230
|
if (req.method === "POST" && path === "/coding/git") {
|
|
218
231
|
const b = await readJson(req);
|
|
219
232
|
try {
|