@proagentstore/cli 0.4.57 → 0.4.59

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.
@@ -14,7 +14,7 @@
14
14
  const HANDLERS = {
15
15
  claude: { clientType: "claude", cliCommand: "claude --dangerously-skip-permissions", envVar: "ANTHROPIC_API_KEY" },
16
16
  gemini: { clientType: "gemini", cliCommand: "gemini --approval-mode yolo --skip-trust --prompt", envVar: "GEMINI_API_KEY" },
17
- codex: { clientType: "codex", cliCommand: "codex exec --sandbox danger-full-access", envVar: "OPENAI_API_KEY" },
17
+ codex: { clientType: "codex", cliCommand: "codex exec --json --sandbox danger-full-access", envVar: "OPENAI_API_KEY" },
18
18
  grok: { clientType: "grok", cliCommand: "grok --permission-mode bypassPermissions -p", envVar: "XAI_API_KEY" },
19
19
  generic: { clientType: "generic", cliCommand: "bash", envVar: "" },
20
20
  };
@@ -10,6 +10,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { dirname, join } from "node:path";
11
11
  import { handlerFor } from "./handlers.js";
12
12
  import { resolveEngineAuth } from "./engine-auth.js";
13
+ import { engineAdapterFor, engineInvocationModeFromAdapter, engineInvocationWarning } from "./engine-adapter.js";
14
+ export { buildClaudeArgs } from "./engine-adapter.js";
13
15
  /**
14
16
  * How many un-drained usage records a session holds (#267).
15
17
  *
@@ -72,6 +74,7 @@ export class HeadlessSession {
72
74
  pendingSeed = null;
73
75
  /** "stream-json" for Claude (structured) · "raw" for any other CLI (stdout capture). */
74
76
  mode;
77
+ adapter;
75
78
  cmdBin;
76
79
  cmdArgs;
77
80
  binName;
@@ -176,6 +179,14 @@ export class HeadlessSession {
176
179
  get ghGuard() {
177
180
  return ghGuardStatus(this.config.ghScope, mergeEnv(process.env, this.config.env), this.config.ghGuardRoot);
178
181
  }
182
+ /** Whether this process is running through structured events or plain stdout (#731). */
183
+ get engineMode() {
184
+ return engineInvocationModeFromAdapter(this.mode);
185
+ }
186
+ /** A named warning only when a structured-capable engine is actually running raw (#731). */
187
+ get engineModeWarning() {
188
+ return engineInvocationWarning(this.config.clientType, this.engineMode);
189
+ }
179
190
  /**
180
191
  * Did this engine launch with a conversation to continue (#408)?
181
192
  *
@@ -189,7 +200,7 @@ export class HeadlessSession {
189
200
  * flag and {@link buildClaudeArgs} is only reached in stream-json mode.
190
201
  */
191
202
  get resumedConversation() {
192
- return this.mode === "stream-json" && this.claudeSessionId !== null;
203
+ return this.config.clientType === "claude" && this.mode === "stream-json" && this.claudeSessionId !== null;
193
204
  }
194
205
  /**
195
206
  * Did this engine come up cold AND with a brief to lead its first turn (ADR 0005, #693)?
@@ -206,13 +217,10 @@ export class HeadlessSession {
206
217
  this.config = config;
207
218
  this.engineLabel = `${config.clientType}:${config.id}`;
208
219
  // Our own key first, the cloud's nominated predecessor second. See `resumeFrom`.
209
- this.claudeSessionId = readState(config.statePath, config.id) ?? (config.resumeFrom ? readState(config.statePath, config.resumeFrom) : null);
210
- // Claude is the structured engine; everything else is a raw CLI.
211
- this.mode = config.clientType === "claude" ? "stream-json" : "raw";
212
- // AFTER both lines above, because `resumedConversation` reads them: the brief is the fallback,
213
- // so an engine that found its own conversation drops it unread rather than being handed a
214
- // summary of the conversation it is already in.
215
- this.pendingSeed = this.resumedConversation ? null : config.seed?.trim() || null;
220
+ this.claudeSessionId =
221
+ config.clientType === "claude"
222
+ ? readState(config.statePath, config.id) ?? (config.resumeFrom ? readState(config.statePath, config.resumeFrom) : null)
223
+ : null;
216
224
  const { bin, args } = parseCommand(config.command);
217
225
  // When no explicit command is configured, fall back to THIS engine's default
218
226
  // command (codex/gemini/grok/…) — not a hard-coded "claude", which would drive a
@@ -223,6 +231,12 @@ export class HeadlessSession {
223
231
  // Use the configured command's args when a command was given (bin set), else the
224
232
  // engine default's args.
225
233
  this.cmdArgs = bin ? args : fallback.args;
234
+ this.adapter = engineAdapterFor(config.clientType, this.cmdArgs);
235
+ this.mode = this.adapter.mode;
236
+ // AFTER both lines above, because `resumedConversation` reads them: the brief is the fallback,
237
+ // so an engine that found its own conversation drops it unread rather than being handed a
238
+ // summary of the conversation it is already in.
239
+ this.pendingSeed = this.resumedConversation ? null : config.seed?.trim() || null;
226
240
  this.binName = (this.cmdBin.split("/").pop() || this.cmdBin) || "cli";
227
241
  }
228
242
  /**
@@ -287,7 +301,7 @@ export class HeadlessSession {
287
301
  // The ceiling that stops a wedged process is armed in `runOneShot` — it ENDS the turn
288
302
  // rather than relabelling a live one as idle, which is the same mistake in slower form.
289
303
  if (this.oneShot)
290
- return this.procAlive ? "thinking" : "idle";
304
+ return this.mode === "stream-json" ? this.run : this.procAlive ? "thinking" : "idle";
291
305
  // Below: a PERSISTENT non-Claude engine — alive between turns, so exit says nothing about
292
306
  // a turn and idle must be inferred. None ships today; every raw engine is one-shot. The
293
307
  // gate is `!oneShot` rather than `mode === "raw"` because the latter now means the
@@ -339,7 +353,7 @@ export class HeadlessSession {
339
353
  * multi-turn, which is why it survived the migration untouched.
340
354
  */
341
355
  get oneShot() {
342
- return this.mode === "raw";
356
+ return !this.adapter.persistent;
343
357
  }
344
358
  start() {
345
359
  // Starting always un-stops: `stop()` is what ends a one-shot session, so a (re)start
@@ -354,10 +368,7 @@ export class HeadlessSession {
354
368
  }
355
369
  if (this.procAlive)
356
370
  return;
357
- // stream-json: we own the structural flags + --resume; merge the user's extras
358
- // (e.g. --model) without letting them clobber or orphan-value our flags. raw:
359
- // run exactly what the user configured and capture stdout.
360
- const args = this.mode === "stream-json" ? buildClaudeArgs(this.cmdArgs, this.claudeSessionId) : [...this.cmdArgs];
371
+ const args = this.adapter.buildLaunchArgs(this.cmdArgs, this.claudeSessionId);
361
372
  const proc = spawn(this.cmdBin, args, {
362
373
  cwd: this.config.workDir,
363
374
  env: this.spawnEnv,
@@ -427,7 +438,7 @@ export class HeadlessSession {
427
438
  // Brief first, instruction second, and never the other way round: it is background for
428
439
  // the request, and an engine that reads the request last acts on the request.
429
440
  const withSeed = seed ? `${seed}\n\n${sent}` : sent;
430
- if (this.mode === "stream-json") {
441
+ if (this.adapter.persistent) {
431
442
  // `role` stays "user" — the only role this protocol accepts, which is why the
432
443
  // disambiguation rides in the text instead (#505).
433
444
  const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: withSeed }] } });
@@ -462,7 +473,7 @@ export class HeadlessSession {
462
473
  // Arm the per-turn line capture BEFORE the spawn, so a report can only ever carry a line
463
474
  // this turn produced (#545).
464
475
  this.turnLastLine = "";
465
- const proc = spawn(this.cmdBin, [...this.cmdArgs, text], {
476
+ const proc = spawn(this.cmdBin, this.adapter.buildTurnArgs(this.cmdArgs, text), {
466
477
  cwd: this.config.workDir,
467
478
  env: this.spawnEnv,
468
479
  stdio: ["ignore", "pipe", "pipe"],
@@ -619,79 +630,65 @@ export class HeadlessSession {
619
630
  this.transcript = this.transcript.slice(-3000);
620
631
  }
621
632
  handle(line) {
622
- let ev;
623
- try {
624
- ev = JSON.parse(line);
625
- }
626
- catch {
627
- return; // tolerate non-JSON noise
628
- }
629
- switch (ev.type) {
630
- case "system":
631
- if (ev.subtype === "init" && ev.session_id) {
632
- this.claudeSessionId = ev.session_id;
633
- writeState(this.config.statePath, this.config.id, ev.session_id);
634
- }
635
- break;
636
- case "assistant":
637
- for (const block of ev.message?.content ?? []) {
638
- if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
639
- this.push(`[${stamp()}] ${block.text.trim()}`); // timestamped agent reply
640
- }
641
- else if (block.type === "tool_use") {
642
- const name = String(block.name ?? "tool");
643
- this.push(`⚙ ${name} ${shortInput(block.input)}`); // ⚙
644
- // The result arrives in a LATER event carrying only `tool_use_id`, and how much
645
- // of it reaches the pane depends on which tool it was (#700) — so the name is
646
- // remembered here and read back in `settleAct`'s sibling branch below.
647
- if (typeof block.id === "string" && block.id)
648
- this.toolNames.set(block.id, name);
649
- this.noteAct(block);
633
+ for (const ev of this.adapter.parseLine(line)) {
634
+ switch (ev.kind) {
635
+ case "session":
636
+ if (this.config.clientType === "claude") {
637
+ this.claudeSessionId = ev.sessionId;
638
+ writeState(this.config.statePath, this.config.id, ev.sessionId);
650
639
  }
640
+ break;
641
+ case "assistant_text":
642
+ this.push(`[${stamp()}] ${ev.text}`); // timestamped agent reply
643
+ break;
644
+ case "tool_use":
645
+ this.push(`⚙ ${ev.name} ${shortInput(ev.input)}`); // ⚙
646
+ // The result arrives in a LATER event carrying only `tool_use_id`, and how much
647
+ // of it reaches the pane depends on which tool it was (#700) — so the name is
648
+ // remembered here and read back in `settleAct`'s sibling branch below.
649
+ if (ev.id)
650
+ this.toolNames.set(ev.id, ev.name);
651
+ this.noteAct(ev.block);
652
+ break;
653
+ case "tool_result": {
654
+ // `""` when the call was not seen (a pane that began mid-turn, a runner restart):
655
+ // an unknown tool takes the conservative budget rather than the generous one.
656
+ const tool = this.toolNames.get(ev.toolUseId) ?? "";
657
+ this.toolNames.delete(ev.toolUseId);
658
+ this.push(renderToolResult(toolResultMark(ev.block), ev.content, tool)); // ↳✓ / ↳✗ (#597)
659
+ this.settleAct(ev.block);
660
+ break;
651
661
  }
652
- break;
653
- case "user": // tool results come back as a synthetic user message
654
- for (const block of ev.message?.content ?? []) {
655
- if (block.type === "tool_result") {
656
- const id = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
657
- // `""` when the call was not seen (a pane that began mid-turn, a runner restart):
658
- // an unknown tool takes the conservative budget rather than the generous one.
659
- const tool = this.toolNames.get(id) ?? "";
660
- this.toolNames.delete(id);
661
- this.push(renderToolResult(toolResultMark(block), block.content, tool)); // ↳✓ / ↳✗ (#597)
662
- this.settleAct(block);
662
+ case "turn_end": {
663
+ const failure = ev.isError ? ev.result : "";
664
+ if (failure)
665
+ this.push(`[error] ${failure}`);
666
+ // The structured path's ANALOGUE of a non-zero exit (#545). Claude has no process
667
+ // per turn, so `exitCode` is honestly null and the verdict comes from the protocol's
668
+ // own `is_error` — the same claim, in the words the engine states it in. Without
669
+ // this the field would exist for three engines and silently not for the flagship.
670
+ this.turnReport = turnReportFromResult(ev.isError, failure);
671
+ // The same event that ends the turn also reports what the turn COST (#267). It was
672
+ // parsed and thrown away, which is why Engine spend was absent from the ledger.
673
+ // An errored turn still burned tokens, so this is recorded regardless of is_error.
674
+ const usage = parseEngineUsage(ev.raw, `${this.config.id}:${this.usageRunId}:${this.usageSeq++}`);
675
+ if (usage) {
676
+ this.pendingUsage.push(usage);
677
+ if (this.pendingUsage.length > MAX_PENDING_USAGE)
678
+ this.pendingUsage.shift();
663
679
  }
680
+ // The turn ended, so no further `tool_result` is coming for anything still waiting.
681
+ // Publish it with an UNKNOWN outcome rather than dropping it: "it ran this and we
682
+ // never saw whether it worked" is a materially different claim from silence, and
683
+ // silence is what a supervisor would read as "it did nothing".
684
+ this.flushAwaitingActs();
685
+ this.toolNames.clear(); // no further result is coming for anything still named here
686
+ this.run = "idle"; // the turn is OVER — a fact, not a guess
687
+ break;
664
688
  }
665
- break;
666
- case "result": {
667
- const failure = ev.is_error ? String(ev.result ?? ev.subtype ?? "failed") : "";
668
- if (failure)
669
- this.push(`[error] ${failure}`);
670
- // The structured path's ANALOGUE of a non-zero exit (#545). Claude has no process
671
- // per turn, so `exitCode` is honestly null and the verdict comes from the protocol's
672
- // own `is_error` — the same claim, in the words the engine states it in. Without
673
- // this the field would exist for three engines and silently not for the flagship.
674
- this.turnReport = turnReportFromResult(ev.is_error === true, failure);
675
- // The same event that ends the turn also reports what the turn COST (#267). It was
676
- // parsed and thrown away, which is why Engine spend was absent from the ledger.
677
- // An errored turn still burned tokens, so this is recorded regardless of is_error.
678
- const usage = parseEngineUsage(ev, `${this.config.id}:${this.usageRunId}:${this.usageSeq++}`);
679
- if (usage) {
680
- this.pendingUsage.push(usage);
681
- if (this.pendingUsage.length > MAX_PENDING_USAGE)
682
- this.pendingUsage.shift();
683
- }
684
- // The turn ended, so no further `tool_result` is coming for anything still waiting.
685
- // Publish it with an UNKNOWN outcome rather than dropping it: "it ran this and we
686
- // never saw whether it worked" is a materially different claim from silence, and
687
- // silence is what a supervisor would read as "it did nothing".
688
- this.flushAwaitingActs();
689
- this.toolNames.clear(); // no further result is coming for anything still named here
690
- this.run = "idle"; // the turn is OVER — a fact, not a guess
691
- break;
689
+ default:
690
+ break;
692
691
  }
693
- default:
694
- break;
695
692
  }
696
693
  // Keep the in-memory transcript bounded. Counts ENTRIES, and an entry may now be a
697
694
  // multi-line block (a result, or a long assistant reply) rather than one line — the
@@ -724,10 +721,11 @@ export class HeadlessSession {
724
721
  * from the RAW `block.content`, never from `renderToolResult()`'s display lines — those are cut to
725
722
  * the pane's budget (`transcript-lines.ts`) and would drop the URL off a verbose result.
726
723
  *
727
- * This path (and `noteAct`) is reachable ONLY from the structured stream-json handling above
728
- * (`assistant` → `tool_use`, `user` → `tool_result`). A Codex/Grok session is a raw spawn with no
729
- * such framing, so its PRs stay unattributed by construction; scraping its transcript instead
730
- * would be the temporal guess `pull-attribution.ts` refuses.
724
+ * This path (and `noteAct`) is reachable ONLY from structured adapter events. Claude emits
725
+ * `assistant` → `tool_use`, `user` → `tool_result`; Codex `exec --json` emits
726
+ * `command_execution`, which the adapter normalizes to the same shape. Raw engines have no such
727
+ * framing, so scraping their transcript would be the temporal guess `pull-attribution.ts`
728
+ * refuses.
731
729
  */
732
730
  settleAct(block) {
733
731
  const id = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
@@ -844,38 +842,6 @@ export function parseCommand(command) {
844
842
  }
845
843
  return { bin: tokens[0] ?? "", args: tokens.slice(1) };
846
844
  }
847
- /** Structural flags PAGS owns for the Claude stream-json engine — a user command
848
- * must not override or duplicate these (and must not orphan their values). */
849
- const RESERVED_CLAUDE_FLAGS = new Set(["-p", "--print", "--input-format", "--output-format", "--verbose", "--resume"]);
850
- /**
851
- * Build Claude's argv: our structural stream-json flags + the user's extra args
852
- * (e.g. `--model`), with reserved flags (and their values) stripped so the user
853
- * can't clobber the protocol or leave an orphaned positional. `--resume` is added
854
- * last from our persisted session id, never from the user's command.
855
- */
856
- export function buildClaudeArgs(userArgs, resumeId) {
857
- const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"];
858
- for (let i = 0; i < userArgs.length; i++) {
859
- const a = userArgs[i];
860
- if (RESERVED_CLAUDE_FLAGS.has(a)) {
861
- // drop the flag AND its value (when the next token isn't itself a flag)
862
- if (i + 1 < userArgs.length && !userArgs[i + 1].startsWith("-"))
863
- i++;
864
- continue;
865
- }
866
- // Push every user token as-is. A previous `!args.includes(a)` dedup silently
867
- // dropped a REPEATED flag token (e.g. the 2nd `--add-dir` in `--add-dir /a
868
- // --add-dir /b`), which orphaned its value (`/b` became a stray positional).
869
- // Our own structural flags are already protected via RESERVED_CLAUDE_FLAGS, so
870
- // no dedup is needed here.
871
- args.push(a);
872
- }
873
- if (!args.includes("--dangerously-skip-permissions"))
874
- args.push("--dangerously-skip-permissions");
875
- if (resumeId)
876
- args.push("--resume", resumeId);
877
- return args;
878
- }
879
845
  function loadFile(path) {
880
846
  if (!path || !existsSync(path))
881
847
  return {};
@@ -1,6 +1,52 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
3
  import { relative, resolve, sep } from "node:path";
4
+ /**
5
+ * Is `dir` inside a git WORK TREE — not merely "does it contain `.git`" (#785)?
6
+ *
7
+ * Three functions in this file gated on `existsSync(resolve(workDir, ".git"))`, while
8
+ * `checkWorkdir` (repo.ts, #405) deliberately asks `git rev-parse --is-inside-work-tree` because
9
+ * `~/dev/monorepo/apps/thing` is a perfectly good workdir with no `.git` of its own. So the
10
+ * staleness check called a subdirectory workdir healthy and `repo_git` in the same folder answered
11
+ * "not a git repo". One question, one answer: this is the gate every git-running function uses.
12
+ *
13
+ * A missing directory, a missing git binary and a plain folder all read as `false` — every one
14
+ * of them makes the git command that follows fail, and "not a git repo" is the message
15
+ * `saysNotAGitRepo` (workers/api/src/lib/repo-state.ts) already matches on the cloud side.
16
+ */
17
+ export function insideWorkTree(dir) {
18
+ try {
19
+ const out = execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
20
+ cwd: dir,
21
+ encoding: "utf-8",
22
+ timeout: 10_000,
23
+ stdio: ["ignore", "pipe", "ignore"],
24
+ });
25
+ return out.trim() === "true";
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ /** The one gate. The message is load-bearing: the cloud matches "not a git repo" (#548). */
32
+ function requireWorkTree(workDir) {
33
+ if (!insideWorkTree(workDir))
34
+ throw new InspectError("not a git repo");
35
+ }
36
+ /**
37
+ * Environment for any git command that may touch the NETWORK (#785).
38
+ *
39
+ * A fetch that hits an expired credential must fail, not hang the runner's request loop on a
40
+ * password prompt nobody can see: `GIT_TERMINAL_PROMPT=0` for https, `BatchMode=yes` for ssh.
41
+ * The user's own `GIT_SSH_COMMAND` wins when set — it may carry a key or a proxy we must keep.
42
+ */
43
+ export function networkGitEnv() {
44
+ return {
45
+ ...process.env,
46
+ GIT_TERMINAL_PROMPT: "0",
47
+ GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes",
48
+ };
49
+ }
4
50
  /**
5
51
  * Read-only code inspection for the coding runtime — the "eyes" the Co-pilot/Chat use
6
52
  * to GROUND their answers in the real repo (read a file, `git diff`, list the tree)
@@ -34,10 +80,33 @@ export function resolveInside(root, rel, opts = {}) {
34
80
  }
35
81
  return abs;
36
82
  }
83
+ /**
84
+ * A revision a caller may name (#785). One shape, checked once, used by every command that takes
85
+ * a `ref`.
86
+ *
87
+ * The rule that matters is the FIRST character: git reads a leading `-` as a flag, so a ref may not
88
+ * start with one, and then no character class below can become an option. Everything else git
89
+ * accepts as a revision is allowed — a sha, `HEAD~3`, `origin/main`, `v1.2`, `main..feature`,
90
+ * `@{u}` — because all of those are READS and the argv around them is fixed.
91
+ */
92
+ const REF_PATTERN = /^[A-Za-z0-9_@][A-Za-z0-9._/^~@{}-]{0,127}$/;
93
+ export function validateRef(ref) {
94
+ const r = ref.trim();
95
+ if (!r)
96
+ throw new InspectError("`ref` is empty");
97
+ if (!REF_PATTERN.test(r))
98
+ throw new InspectError(`\`ref\` is not a valid git revision: ${ref.slice(0, 64)}`);
99
+ return r;
100
+ }
37
101
  /** 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. */
102
+ * caller via resolveInside) is only ever appended after a literal `--` separator, and `ref`
103
+ * (already validated by `validateRef`) only ever lands where git expects a revision. */
39
104
  export function gitArgv(cmd, opts = {}) {
40
105
  const clampN = Math.max(1, Math.min(200, Math.floor(opts.n ?? 20)));
106
+ const path = opts.relPath ? ["--", opts.relPath] : [];
107
+ // `ref` is a REVISION and belongs before `--`; `path` is a PATHSPEC and belongs after it. That
108
+ // ordering is what keeps the two from being confused for each other by git, whatever they hold.
109
+ const rev = opts.ref ? [opts.ref] : [];
41
110
  switch (cmd) {
42
111
  case "status":
43
112
  // `--branch` adds ONE header line (`## main...origin/main [ahead 1]`). Without it a
@@ -48,7 +117,7 @@ export function gitArgv(cmd, opts = {}) {
48
117
  // `path` reaches this one too (#508). Narrowing every command rather than four of the
49
118
  // five is what lets the tool description say "it applies" with no caveat — and a
50
119
  // 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"];
120
+ return ["status", "--short", "--branch", ...path];
52
121
  // `path` used to reach exactly ONE of these five (#508). It is advertised on the tool as
53
122
  // "Limit the command to one file or folder", `runRepoGit` resolves and validates it, and
54
123
  // then four of the five branches dropped it on the floor — so
@@ -60,13 +129,24 @@ export function gitArgv(cmd, opts = {}) {
60
129
  // Every one of these is git's own `--` pathspec discipline, unchanged: the validated path
61
130
  // is appended after a literal separator and can never be read as a flag or a revision.
62
131
  case "diff":
63
- return opts.relPath ? ["diff", "--", opts.relPath] : ["diff"];
132
+ return ["diff", ...rev, ...path];
64
133
  case "diff-stat":
65
- return opts.relPath ? ["diff", "--stat", "--", opts.relPath] : ["diff", "--stat"];
134
+ return ["diff", "--stat", ...rev, ...path];
135
+ // `ref` on `log` is what #785 reached for as `git log -1 <sha>` and found silently ignored:
136
+ // the input never existed, so the tool answered the canned 20-line list and nothing said
137
+ // the argument had gone nowhere. Now `{cmd:"log", ref, n:1}` is that command.
66
138
  case "log":
67
- return opts.relPath ? ["log", "--oneline", "-n", String(clampN), "--", opts.relPath] : ["log", "--oneline", "-n", String(clampN)];
139
+ return ["log", "--oneline", "-n", String(clampN), ...rev, ...path];
68
140
  case "ls-files":
69
- return opts.relPath ? ["ls-files", "--", opts.relPath] : ["ls-files"];
141
+ return ["ls-files", ...path];
142
+ // `show` is `--stat <sha>`, the other thing #785 tried: what ONE commit changed. `--stat`
143
+ // rather than the patch, because the patch of an arbitrary commit is unbounded and the
144
+ // file list is what a reader deciding whether to `diff` one file actually needs. A `show`
145
+ // with no ref would show HEAD, which is a guess dressed as an answer — required instead.
146
+ case "show":
147
+ if (!rev.length)
148
+ throw new InspectError("`show` needs a `ref` — the commit to describe");
149
+ return ["show", "--stat", "--format=medium", ...rev, ...path];
70
150
  default:
71
151
  throw new InspectError(`unsupported git command: ${cmd}`);
72
152
  }
@@ -96,16 +176,20 @@ export function readRepoFile(workDir, relPath, maxBytes) {
96
176
  }
97
177
  /** Run a whitelisted read-only git command in the repo. Never uses a shell. */
98
178
  export function runRepoGit(workDir, cmd, opts = {}) {
99
- if (!existsSync(resolve(workDir, ".git")))
100
- throw new InspectError("not a git repo");
179
+ requireWorkTree(workDir);
101
180
  const relPath = opts.path ? relative(workDir, resolveInside(workDir, opts.path)) : undefined;
102
- const argv = gitArgv(cmd, { relPath, n: opts.n });
181
+ const ref = opts.ref ? validateRef(opts.ref) : undefined;
182
+ const argv = gitArgv(cmd, { relPath, n: opts.n, ref });
103
183
  // Did the path the caller asked for actually reach git? Reported rather than assumed, because
104
184
  // a runner is a SEPARATE release from the cloud that calls it: before #508 four of the five
105
185
  // commands ignored `path` silently, and the answer — the whole repository — was indistinguishable
106
186
  // from a correct one. An older runner omits this field entirely, which is what lets the cloud
107
187
  // say "your machine ignored the filter" instead of relaying a wrong answer as a right one.
108
188
  const pathApplied = relPath !== undefined && argv.includes(relPath);
189
+ // Same idiom for `ref` (#785): a runner older than this drops it, and the cloud tells the caller
190
+ // so — the alternative is the exact silence the issue reported, a `log -1 <sha>` answered by
191
+ // the newest twenty commits with nothing to say the sha went nowhere.
192
+ const refApplied = ref !== undefined && argv.includes(ref);
109
193
  let out = "";
110
194
  try {
111
195
  out = execFileSync("git", argv, { cwd: workDir, encoding: "utf-8", timeout: 10_000, maxBuffer: 4 * 1024 * 1024 });
@@ -119,13 +203,13 @@ export function runRepoGit(workDir, cmd, opts = {}) {
119
203
  }
120
204
  const cap = opts.maxBytes ?? 64 * 1024;
121
205
  const truncated = out.length > cap;
122
- return { cmd, output: truncated ? out.slice(0, cap) : out, truncated, pathApplied };
206
+ return { cmd, output: truncated ? out.slice(0, cap) : out, truncated, pathApplied, refApplied };
123
207
  }
124
208
  /** Read the repo's `origin` remote URL — used to auto-associate a local checkout with its
125
209
  * GitHub repo (so build status can query Actions). Fixed argv, no shell, no user input;
126
210
  * returns null when it's not a git repo or has no `origin` remote. */
127
211
  export function readGitRemoteOrigin(workDir) {
128
- if (!existsSync(resolve(workDir, ".git")))
212
+ if (!insideWorkTree(workDir))
129
213
  return null;
130
214
  try {
131
215
  const out = execFileSync("git", ["config", "--get", "remote.origin.url"], {
@@ -178,8 +262,7 @@ const SEARCH_PER_FILE = 5;
178
262
  const SEARCH_MAX_LINE = 160;
179
263
  const SEARCH_MAX_PATTERN = 200;
180
264
  export function repoSearch(workDir, opts) {
181
- if (!existsSync(resolve(workDir, ".git")))
182
- throw new InspectError("not a git repo");
265
+ requireWorkTree(workDir);
183
266
  const pattern = (opts.pattern ?? "").trim();
184
267
  if (!pattern)
185
268
  throw new InspectError("a search `pattern` is required");
@@ -306,3 +389,95 @@ export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500)
306
389
  }
307
390
  return { root: relPath, entries, truncated, truncatedByDepth, depthCap };
308
391
  }
392
+ export const SYNC_FETCH_TTL_MS = 60_000;
393
+ /** Per work-tree root: when the last fetch attempt was made and how it went. */
394
+ const fetchLog = new Map();
395
+ /** Test seam: forget every cached fetch. */
396
+ export function resetSyncCache() {
397
+ fetchLog.clear();
398
+ }
399
+ export function repoSync(workDir, opts = {}) {
400
+ requireWorkTree(workDir);
401
+ const now = opts.now ?? Date.now;
402
+ const git = (args, timeout = 10_000) => execFileSync("git", args, { cwd: workDir, encoding: "utf-8", timeout, stdio: ["ignore", "pipe", "pipe"], env: networkGitEnv() }).trim();
403
+ const tryGit = (args, timeout) => {
404
+ try {
405
+ return git(args, timeout);
406
+ }
407
+ catch {
408
+ return null;
409
+ }
410
+ };
411
+ const root = tryGit(["rev-parse", "--show-toplevel"]) ?? resolve(workDir);
412
+ const abbrev = tryGit(["rev-parse", "--abbrev-ref", "HEAD"]);
413
+ const branch = abbrev && abbrev !== "HEAD" ? abbrev : null;
414
+ // The upstream git itself records for the branch, else the same-named branch on `origin` if
415
+ // that ref exists, else the configured branch on `origin`. Named rather than assumed: a
416
+ // `main` checkout whose upstream is `upstream/main` must be compared against THAT.
417
+ let upstream = tryGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
418
+ if (!upstream) {
419
+ for (const candidate of [branch, opts.branch].filter((b) => Boolean(b))) {
420
+ if (tryGit(["rev-parse", "--verify", "--quiet", `origin/${candidate}`]) !== null) {
421
+ upstream = `origin/${candidate}`;
422
+ break;
423
+ }
424
+ }
425
+ }
426
+ const remote = upstream?.includes("/") ? upstream.slice(0, upstream.indexOf("/")) : "origin";
427
+ const entry = fetchLog.get(root);
428
+ const t = now();
429
+ if (opts.forceFetch || !entry || t - entry.at > SYNC_FETCH_TTL_MS) {
430
+ try {
431
+ // `--quiet` so a successful fetch prints nothing to stderr worth parsing; 20s because a
432
+ // fetch is a network call and the relay's read timeout is above that.
433
+ git(["fetch", "--quiet", remote], 20_000);
434
+ fetchLog.set(root, { at: t, error: null, okAt: t });
435
+ }
436
+ catch (e) {
437
+ const err = e;
438
+ // git's stderr is several lines and the diagnosis is the FIRST `fatal:`/`error:` one —
439
+ // the tail is boilerplate ("and the repository exists.") that names nothing.
440
+ const lines = String(err.stderr || err.message || "")
441
+ .split("\n")
442
+ .map((l) => l.trim())
443
+ .filter(Boolean);
444
+ const detail = lines.find((l) => /^(fatal|error):/i.test(l)) ?? lines[0] ?? "fetch failed";
445
+ fetchLog.set(root, { at: t, error: detail.replace(/^(fatal|error):\s*/i, "").slice(0, 200), okAt: entry?.okAt ?? null });
446
+ }
447
+ }
448
+ const fetchState = fetchLog.get(root) ?? { at: t, error: null, okAt: null };
449
+ // Upstream may only exist AFTER the first fetch of a fresh clone — look once more.
450
+ if (!upstream) {
451
+ for (const candidate of [branch, opts.branch].filter((b) => Boolean(b))) {
452
+ if (tryGit(["rev-parse", "--verify", "--quiet", `origin/${candidate}`]) !== null) {
453
+ upstream = `origin/${candidate}`;
454
+ break;
455
+ }
456
+ }
457
+ }
458
+ const localHead = tryGit(["rev-parse", "HEAD"]);
459
+ const remoteHead = upstream ? tryGit(["rev-parse", upstream]) : null;
460
+ let ahead = null;
461
+ let behind = null;
462
+ if (upstream && remoteHead) {
463
+ const counts = tryGit(["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
464
+ const m = counts?.match(/^(\d+)\s+(\d+)$/);
465
+ if (m) {
466
+ ahead = Number.parseInt(m[1], 10);
467
+ behind = Number.parseInt(m[2], 10);
468
+ }
469
+ }
470
+ return {
471
+ checked: true,
472
+ path: root,
473
+ branch,
474
+ upstream,
475
+ localHead,
476
+ remoteHead,
477
+ ahead,
478
+ behind,
479
+ fetched: fetchState.okAt !== null && t - fetchState.okAt <= SYNC_FETCH_TTL_MS,
480
+ fetchedAt: fetchState.okAt,
481
+ fetchError: fetchState.error,
482
+ };
483
+ }