@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
- return ["status", "--short", "--branch"];
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
- /** Bounded recursive file tree (names/type/size only — no contents). */
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(4, maxDepth));
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
- entries.push({ path: rel, type: "dir" });
151
- if (depth + 1 < depthCap)
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
- let dirtyAfter = false;
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.48",
3
+ "version": "0.4.50",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",