@proagentstore/cli 0.4.56 → 0.4.58
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.
- package/dist/browser-runner/coding/engine-acts.js +7 -7
- package/dist/browser-runner/coding/engine-adapter.js +186 -0
- package/dist/browser-runner/coding/engine-usage.js +27 -3
- package/dist/browser-runner/coding/github-browse.js +796 -0
- package/dist/browser-runner/coding/handlers.js +1 -1
- package/dist/browser-runner/coding/headless.js +121 -118
- package/dist/browser-runner/coding/inspect.js +188 -13
- package/dist/browser-runner/coding/repo.js +44 -1
- package/dist/browser-runner/coding/runtime.js +20 -3
- package/dist/browser-runner/server.js +103 -0
- package/package.json +1 -1
|
@@ -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
|
*
|
|
@@ -60,8 +62,19 @@ export class HeadlessSession {
|
|
|
60
62
|
run = "idle";
|
|
61
63
|
/** Claude Code's own session id (from the init event) — used to --resume. */
|
|
62
64
|
claudeSessionId = null;
|
|
65
|
+
/**
|
|
66
|
+
* The cloud's context brief, until the first turn spends it (ADR 0005, #693). Null once
|
|
67
|
+
* delivered, and null from the start when the engine resumed its own conversation.
|
|
68
|
+
*
|
|
69
|
+
* Consumed ONCE, by construction rather than by convention: it is cleared in the same expression
|
|
70
|
+
* that reads it. A brief re-sent on every turn would be the "unbounded brief… a token bill that
|
|
71
|
+
* grows every turn" the ADR names as the cost of owning the conversation, and it would also start
|
|
72
|
+
* contradicting the engine's own memory of the turns since.
|
|
73
|
+
*/
|
|
74
|
+
pendingSeed = null;
|
|
63
75
|
/** "stream-json" for Claude (structured) · "raw" for any other CLI (stdout capture). */
|
|
64
76
|
mode;
|
|
77
|
+
adapter;
|
|
65
78
|
cmdBin;
|
|
66
79
|
cmdArgs;
|
|
67
80
|
binName;
|
|
@@ -166,6 +179,14 @@ export class HeadlessSession {
|
|
|
166
179
|
get ghGuard() {
|
|
167
180
|
return ghGuardStatus(this.config.ghScope, mergeEnv(process.env, this.config.env), this.config.ghGuardRoot);
|
|
168
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
|
+
}
|
|
169
190
|
/**
|
|
170
191
|
* Did this engine launch with a conversation to continue (#408)?
|
|
171
192
|
*
|
|
@@ -179,15 +200,27 @@ export class HeadlessSession {
|
|
|
179
200
|
* flag and {@link buildClaudeArgs} is only reached in stream-json mode.
|
|
180
201
|
*/
|
|
181
202
|
get resumedConversation() {
|
|
182
|
-
return this.mode === "stream-json" && this.claudeSessionId !== null;
|
|
203
|
+
return this.config.clientType === "claude" && this.mode === "stream-json" && this.claudeSessionId !== null;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Did this engine come up cold AND with a brief to lead its first turn (ADR 0005, #693)?
|
|
207
|
+
*
|
|
208
|
+
* Read by `/coding/start` so the sentence the user is shown is one this side CONFIRMED. It is
|
|
209
|
+
* armed-not-delivered: the brief goes out with the first `input()`, which may never arrive if
|
|
210
|
+
* the session is closed first. That is why the cloud's wording is "it was given a brief" — true
|
|
211
|
+
* the moment the engine holds one — and never "it has read your history".
|
|
212
|
+
*/
|
|
213
|
+
get seededConversation() {
|
|
214
|
+
return this.pendingSeed !== null;
|
|
183
215
|
}
|
|
184
216
|
constructor(config) {
|
|
185
217
|
this.config = config;
|
|
186
218
|
this.engineLabel = `${config.clientType}:${config.id}`;
|
|
187
219
|
// Our own key first, the cloud's nominated predecessor second. See `resumeFrom`.
|
|
188
|
-
this.claudeSessionId =
|
|
189
|
-
|
|
190
|
-
|
|
220
|
+
this.claudeSessionId =
|
|
221
|
+
config.clientType === "claude"
|
|
222
|
+
? readState(config.statePath, config.id) ?? (config.resumeFrom ? readState(config.statePath, config.resumeFrom) : null)
|
|
223
|
+
: null;
|
|
191
224
|
const { bin, args } = parseCommand(config.command);
|
|
192
225
|
// When no explicit command is configured, fall back to THIS engine's default
|
|
193
226
|
// command (codex/gemini/grok/…) — not a hard-coded "claude", which would drive a
|
|
@@ -198,6 +231,12 @@ export class HeadlessSession {
|
|
|
198
231
|
// Use the configured command's args when a command was given (bin set), else the
|
|
199
232
|
// engine default's args.
|
|
200
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;
|
|
201
240
|
this.binName = (this.cmdBin.split("/").pop() || this.cmdBin) || "cli";
|
|
202
241
|
}
|
|
203
242
|
/**
|
|
@@ -262,7 +301,7 @@ export class HeadlessSession {
|
|
|
262
301
|
// The ceiling that stops a wedged process is armed in `runOneShot` — it ENDS the turn
|
|
263
302
|
// rather than relabelling a live one as idle, which is the same mistake in slower form.
|
|
264
303
|
if (this.oneShot)
|
|
265
|
-
return this.procAlive ? "thinking" : "idle";
|
|
304
|
+
return this.mode === "stream-json" ? this.run : this.procAlive ? "thinking" : "idle";
|
|
266
305
|
// Below: a PERSISTENT non-Claude engine — alive between turns, so exit says nothing about
|
|
267
306
|
// a turn and idle must be inferred. None ships today; every raw engine is one-shot. The
|
|
268
307
|
// gate is `!oneShot` rather than `mode === "raw"` because the latter now means the
|
|
@@ -314,7 +353,7 @@ export class HeadlessSession {
|
|
|
314
353
|
* multi-turn, which is why it survived the migration untouched.
|
|
315
354
|
*/
|
|
316
355
|
get oneShot() {
|
|
317
|
-
return this.
|
|
356
|
+
return !this.adapter.persistent;
|
|
318
357
|
}
|
|
319
358
|
start() {
|
|
320
359
|
// Starting always un-stops: `stop()` is what ends a one-shot session, so a (re)start
|
|
@@ -329,10 +368,7 @@ export class HeadlessSession {
|
|
|
329
368
|
}
|
|
330
369
|
if (this.procAlive)
|
|
331
370
|
return;
|
|
332
|
-
|
|
333
|
-
// (e.g. --model) without letting them clobber or orphan-value our flags. raw:
|
|
334
|
-
// run exactly what the user configured and capture stdout.
|
|
335
|
-
const args = this.mode === "stream-json" ? buildClaudeArgs(this.cmdArgs, this.claudeSessionId) : [...this.cmdArgs];
|
|
371
|
+
const args = this.adapter.buildLaunchArgs(this.cmdArgs, this.claudeSessionId);
|
|
336
372
|
const proc = spawn(this.cmdBin, args, {
|
|
337
373
|
cwd: this.config.workDir,
|
|
338
374
|
env: this.spawnEnv,
|
|
@@ -381,8 +417,17 @@ export class HeadlessSession {
|
|
|
381
417
|
input(text, opts = {}) {
|
|
382
418
|
// The Engine reads the preamble, the pane the short marker; the instruction stays evidence.
|
|
383
419
|
const sent = authoredTurn(text, opts.author);
|
|
420
|
+
// The brief leads the turn and is spent in the reading (#693). It goes to the ENGINE only:
|
|
421
|
+
// the pane gets the one-line marker below, because the transcript is what the owner and the
|
|
422
|
+
// Pilot re-read through `/coding/capture` and twelve thousand characters of reconstructed
|
|
423
|
+
// history would evict the live output they are looking at — the same budget argument
|
|
424
|
+
// `turn-author.ts` makes for its two-word marker.
|
|
425
|
+
const seed = this.pendingSeed;
|
|
426
|
+
this.pendingSeed = null;
|
|
384
427
|
if (!this.alive)
|
|
385
428
|
this.start();
|
|
429
|
+
if (seed)
|
|
430
|
+
this.push("[pags] a context brief from ProAgentStore's record was delivered with this turn — a reconstruction, not the previous conversation");
|
|
386
431
|
this.push(`\n❯ [${stamp()}] ${authorTag(opts.author)}${text}`); // ❯ — your turn, timestamped
|
|
387
432
|
this.run = "thinking";
|
|
388
433
|
const now = Date.now();
|
|
@@ -390,16 +435,19 @@ export class HeadlessSession {
|
|
|
390
435
|
this.turnStartedAt = now;
|
|
391
436
|
this.sawOutputSinceInput = false; // arm the persistent-raw idle heuristic for THIS turn
|
|
392
437
|
try {
|
|
393
|
-
|
|
438
|
+
// Brief first, instruction second, and never the other way round: it is background for
|
|
439
|
+
// the request, and an engine that reads the request last acts on the request.
|
|
440
|
+
const withSeed = seed ? `${seed}\n\n${sent}` : sent;
|
|
441
|
+
if (this.adapter.persistent) {
|
|
394
442
|
// `role` stays "user" — the only role this protocol accepts, which is why the
|
|
395
443
|
// disambiguation rides in the text instead (#505).
|
|
396
|
-
const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text:
|
|
444
|
+
const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: withSeed }] } });
|
|
397
445
|
this.proc?.stdin?.write(`${msg}\n`);
|
|
398
446
|
}
|
|
399
447
|
else {
|
|
400
448
|
// Raw CLI: spawn THIS turn. See `oneShot` — there is no persistent process to
|
|
401
449
|
// write to, because a non-interactive binary would never have read it.
|
|
402
|
-
this.runOneShot(
|
|
450
|
+
this.runOneShot(withSeed);
|
|
403
451
|
}
|
|
404
452
|
}
|
|
405
453
|
catch {
|
|
@@ -425,7 +473,7 @@ export class HeadlessSession {
|
|
|
425
473
|
// Arm the per-turn line capture BEFORE the spawn, so a report can only ever carry a line
|
|
426
474
|
// this turn produced (#545).
|
|
427
475
|
this.turnLastLine = "";
|
|
428
|
-
const proc = spawn(this.cmdBin,
|
|
476
|
+
const proc = spawn(this.cmdBin, this.adapter.buildTurnArgs(this.cmdArgs, text), {
|
|
429
477
|
cwd: this.config.workDir,
|
|
430
478
|
env: this.spawnEnv,
|
|
431
479
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -582,79 +630,65 @@ export class HeadlessSession {
|
|
|
582
630
|
this.transcript = this.transcript.slice(-3000);
|
|
583
631
|
}
|
|
584
632
|
handle(line) {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
}
|
|
592
|
-
switch (ev.type) {
|
|
593
|
-
case "system":
|
|
594
|
-
if (ev.subtype === "init" && ev.session_id) {
|
|
595
|
-
this.claudeSessionId = ev.session_id;
|
|
596
|
-
writeState(this.config.statePath, this.config.id, ev.session_id);
|
|
597
|
-
}
|
|
598
|
-
break;
|
|
599
|
-
case "assistant":
|
|
600
|
-
for (const block of ev.message?.content ?? []) {
|
|
601
|
-
if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
|
|
602
|
-
this.push(`[${stamp()}] ${block.text.trim()}`); // timestamped agent reply
|
|
603
|
-
}
|
|
604
|
-
else if (block.type === "tool_use") {
|
|
605
|
-
const name = String(block.name ?? "tool");
|
|
606
|
-
this.push(`⚙ ${name} ${shortInput(block.input)}`); // ⚙
|
|
607
|
-
// The result arrives in a LATER event carrying only `tool_use_id`, and how much
|
|
608
|
-
// of it reaches the pane depends on which tool it was (#700) — so the name is
|
|
609
|
-
// remembered here and read back in `settleAct`'s sibling branch below.
|
|
610
|
-
if (typeof block.id === "string" && block.id)
|
|
611
|
-
this.toolNames.set(block.id, name);
|
|
612
|
-
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);
|
|
613
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;
|
|
614
661
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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();
|
|
626
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;
|
|
627
688
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
const failure = ev.is_error ? String(ev.result ?? ev.subtype ?? "failed") : "";
|
|
631
|
-
if (failure)
|
|
632
|
-
this.push(`[error] ${failure}`);
|
|
633
|
-
// The structured path's ANALOGUE of a non-zero exit (#545). Claude has no process
|
|
634
|
-
// per turn, so `exitCode` is honestly null and the verdict comes from the protocol's
|
|
635
|
-
// own `is_error` — the same claim, in the words the engine states it in. Without
|
|
636
|
-
// this the field would exist for three engines and silently not for the flagship.
|
|
637
|
-
this.turnReport = turnReportFromResult(ev.is_error === true, failure);
|
|
638
|
-
// The same event that ends the turn also reports what the turn COST (#267). It was
|
|
639
|
-
// parsed and thrown away, which is why Engine spend was absent from the ledger.
|
|
640
|
-
// An errored turn still burned tokens, so this is recorded regardless of is_error.
|
|
641
|
-
const usage = parseEngineUsage(ev, `${this.config.id}:${this.usageRunId}:${this.usageSeq++}`);
|
|
642
|
-
if (usage) {
|
|
643
|
-
this.pendingUsage.push(usage);
|
|
644
|
-
if (this.pendingUsage.length > MAX_PENDING_USAGE)
|
|
645
|
-
this.pendingUsage.shift();
|
|
646
|
-
}
|
|
647
|
-
// The turn ended, so no further `tool_result` is coming for anything still waiting.
|
|
648
|
-
// Publish it with an UNKNOWN outcome rather than dropping it: "it ran this and we
|
|
649
|
-
// never saw whether it worked" is a materially different claim from silence, and
|
|
650
|
-
// silence is what a supervisor would read as "it did nothing".
|
|
651
|
-
this.flushAwaitingActs();
|
|
652
|
-
this.toolNames.clear(); // no further result is coming for anything still named here
|
|
653
|
-
this.run = "idle"; // the turn is OVER — a fact, not a guess
|
|
654
|
-
break;
|
|
689
|
+
default:
|
|
690
|
+
break;
|
|
655
691
|
}
|
|
656
|
-
default:
|
|
657
|
-
break;
|
|
658
692
|
}
|
|
659
693
|
// Keep the in-memory transcript bounded. Counts ENTRIES, and an entry may now be a
|
|
660
694
|
// multi-line block (a result, or a long assistant reply) rather than one line — the
|
|
@@ -687,10 +721,11 @@ export class HeadlessSession {
|
|
|
687
721
|
* from the RAW `block.content`, never from `renderToolResult()`'s display lines — those are cut to
|
|
688
722
|
* the pane's budget (`transcript-lines.ts`) and would drop the URL off a verbose result.
|
|
689
723
|
*
|
|
690
|
-
* This path (and `noteAct`) is reachable ONLY from
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
* would be the temporal guess `pull-attribution.ts`
|
|
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.
|
|
694
729
|
*/
|
|
695
730
|
settleAct(block) {
|
|
696
731
|
const id = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
|
|
@@ -807,38 +842,6 @@ export function parseCommand(command) {
|
|
|
807
842
|
}
|
|
808
843
|
return { bin: tokens[0] ?? "", args: tokens.slice(1) };
|
|
809
844
|
}
|
|
810
|
-
/** Structural flags PAGS owns for the Claude stream-json engine — a user command
|
|
811
|
-
* must not override or duplicate these (and must not orphan their values). */
|
|
812
|
-
const RESERVED_CLAUDE_FLAGS = new Set(["-p", "--print", "--input-format", "--output-format", "--verbose", "--resume"]);
|
|
813
|
-
/**
|
|
814
|
-
* Build Claude's argv: our structural stream-json flags + the user's extra args
|
|
815
|
-
* (e.g. `--model`), with reserved flags (and their values) stripped so the user
|
|
816
|
-
* can't clobber the protocol or leave an orphaned positional. `--resume` is added
|
|
817
|
-
* last from our persisted session id, never from the user's command.
|
|
818
|
-
*/
|
|
819
|
-
export function buildClaudeArgs(userArgs, resumeId) {
|
|
820
|
-
const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"];
|
|
821
|
-
for (let i = 0; i < userArgs.length; i++) {
|
|
822
|
-
const a = userArgs[i];
|
|
823
|
-
if (RESERVED_CLAUDE_FLAGS.has(a)) {
|
|
824
|
-
// drop the flag AND its value (when the next token isn't itself a flag)
|
|
825
|
-
if (i + 1 < userArgs.length && !userArgs[i + 1].startsWith("-"))
|
|
826
|
-
i++;
|
|
827
|
-
continue;
|
|
828
|
-
}
|
|
829
|
-
// Push every user token as-is. A previous `!args.includes(a)` dedup silently
|
|
830
|
-
// dropped a REPEATED flag token (e.g. the 2nd `--add-dir` in `--add-dir /a
|
|
831
|
-
// --add-dir /b`), which orphaned its value (`/b` became a stray positional).
|
|
832
|
-
// Our own structural flags are already protected via RESERVED_CLAUDE_FLAGS, so
|
|
833
|
-
// no dedup is needed here.
|
|
834
|
-
args.push(a);
|
|
835
|
-
}
|
|
836
|
-
if (!args.includes("--dangerously-skip-permissions"))
|
|
837
|
-
args.push("--dangerously-skip-permissions");
|
|
838
|
-
if (resumeId)
|
|
839
|
-
args.push("--resume", resumeId);
|
|
840
|
-
return args;
|
|
841
|
-
}
|
|
842
845
|
function loadFile(path) {
|
|
843
846
|
if (!path || !existsSync(path))
|
|
844
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
|
+
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
|
|
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
|
|
132
|
+
return ["diff", ...rev, ...path];
|
|
64
133
|
case "diff-stat":
|
|
65
|
-
return
|
|
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
|
|
139
|
+
return ["log", "--oneline", "-n", String(clampN), ...rev, ...path];
|
|
68
140
|
case "ls-files":
|
|
69
|
-
return
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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
|
+
}
|