pullfrog 0.1.96 → 0.1.97

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/cli.mjs CHANGED
@@ -98361,14 +98361,14 @@ var require_turndown_cjs = __commonJS({
98361
98361
  } else if (node2.nodeType === 1) {
98362
98362
  replacement = replacementForNode.call(self2, node2);
98363
98363
  }
98364
- return join30(output, replacement);
98364
+ return join31(output, replacement);
98365
98365
  }, "");
98366
98366
  }
98367
98367
  function postProcess(output) {
98368
98368
  var self2 = this;
98369
98369
  this.rules.forEach(function(rule) {
98370
98370
  if (typeof rule.append === "function") {
98371
- output = join30(output, rule.append(self2.options));
98371
+ output = join31(output, rule.append(self2.options));
98372
98372
  }
98373
98373
  });
98374
98374
  return output.replace(/^[\t\r\n]+/, "").replace(/[\t\r\n\s]+$/, "");
@@ -98380,7 +98380,7 @@ var require_turndown_cjs = __commonJS({
98380
98380
  if (whitespace.leading || whitespace.trailing) content = content.trim();
98381
98381
  return whitespace.leading + rule.replacement(content, node2, this.options) + whitespace.trailing;
98382
98382
  }
98383
- function join30(output, replacement) {
98383
+ function join31(output, replacement) {
98384
98384
  var s1 = trimTrailingNewlines(output);
98385
98385
  var s2 = trimLeadingNewlines(replacement);
98386
98386
  var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
@@ -106963,7 +106963,7 @@ var import_picocolors = __toESM(require_picocolors(), 1);
106963
106963
  // package.json
106964
106964
  var package_default = {
106965
106965
  name: "pullfrog",
106966
- version: "0.1.96",
106966
+ version: "0.1.97",
106967
106967
  cliContractVersion: "1",
106968
106968
  type: "module",
106969
106969
  bin: {
@@ -108607,9 +108607,9 @@ var import_arg6 = __toESM(require_arg(), 1);
108607
108607
  import { dirname as dirname6 } from "node:path";
108608
108608
 
108609
108609
  // main.ts
108610
- import { existsSync as existsSync12, readdirSync as readdirSync2 } from "node:fs";
108610
+ import { existsSync as existsSync13, readdirSync as readdirSync2 } from "node:fs";
108611
108611
  import { readFile as readFile6 } from "node:fs/promises";
108612
- import { join as join28 } from "node:path";
108612
+ import { join as join29 } from "node:path";
108613
108613
 
108614
108614
  // node_modules/.pnpm/package-manager-detector@1.8.0/node_modules/package-manager-detector/dist/commands.mjs
108615
108615
  function splitRunArgs(args2, valueFlags = []) {
@@ -109618,6 +109618,15 @@ var providers = {
109618
109618
  openRouterEffort: [],
109619
109619
  openRouterResolve: "openrouter/qwen/qwen3.8-flash"
109620
109620
  },
109621
+ // Zen lists Mistral Large 4 at half its list price for launch, level with
109622
+ // the routers. hidden until a published `pullfrog` resolves it, as there.
109623
+ "mistral-large": {
109624
+ displayName: "Mistral Large",
109625
+ resolve: "opencode/mistral-large-4",
109626
+ effort: ["none", "high"],
109627
+ openRouterResolve: "openrouter/mistralai/mistral-large-4-0",
109628
+ hidden: true
109629
+ },
109621
109630
  "gpt-5-nano": {
109622
109631
  displayName: "GPT Nano",
109623
109632
  resolve: "opencode/gpt-5.4-nano",
@@ -109666,12 +109675,12 @@ var providers = {
109666
109675
  fallback: "opencode/big-pickle"
109667
109676
  },
109668
109677
  // Zen's FREE stealth preview (listed 2026-09-23), the third after
109669
- // big-pickle and union-alpha. OpenRouter's listing: prompts and
109670
- // completions may be retained by the provider, not trained on. unlike the
109671
- // first two it publishes an effort ladder, so the gate mirrors one here.
109678
+ // big-pickle and union-alpha. Zen's docs: zero retention, no training.
109679
+ // unlike the first two it publishes an effort ladder, so the gate
109680
+ // mirrors one here.
109672
109681
  "space-bunny": {
109673
109682
  displayName: "Space Bunny",
109674
- description: "Stealth preview; prompts may be retained",
109683
+ description: "Stealth preview, free for a limited time",
109675
109684
  resolve: "opencode/space-bunny-free",
109676
109685
  effort: ["low", "medium", "high", "xhigh", "max"],
109677
109686
  // free to run, still gated on the provider's own OPENCODE_API_KEY —
@@ -109689,17 +109698,30 @@ var providers = {
109689
109698
  // see the big-pickle note above (#1077).
109690
109699
  isFree: true
109691
109700
  },
109692
- // Zen's FREE stealth preview (listed 2026-10-01), the fifth promo. Zen
109693
- // has not published its data terms yet, so the description claims none.
109701
+ // Zen's FREE stealth preview (listed 2026-10-01), the fifth promo. Zen's
109702
+ // docs: data may be used to improve the model during the free period.
109694
109703
  "fledge-alpha": {
109695
109704
  displayName: "Fledge Alpha",
109696
- description: "Stealth preview, free for a limited time",
109705
+ description: "Stealth preview; data may be used to improve it",
109697
109706
  resolve: "opencode/fledge-alpha-free",
109698
109707
  effort: ["low", "high", "max"],
109699
109708
  // free to run, still gated on the provider's own OPENCODE_API_KEY —
109700
109709
  // see the big-pickle note above (#1077).
109701
109710
  isFree: true
109702
109711
  },
109712
+ // Zen's FREE preview (listed 2026-10-06), the sixth promo, on Fledge
109713
+ // Alpha's terms. hidden until a published `pullfrog` resolves it.
109714
+ exo: {
109715
+ displayName: "Exo",
109716
+ description: "Preview; data may be used to improve it",
109717
+ resolve: "opencode/exo-free",
109718
+ // Zen publishes a single rung.
109719
+ effort: ["high"],
109720
+ // free to run, still gated on the provider's own OPENCODE_API_KEY —
109721
+ // see the big-pickle note above (#1077).
109722
+ isFree: true,
109723
+ hidden: true
109724
+ },
109703
109725
  // Zen's live free MiMo, and the second free row in a menu that big-pickle
109704
109726
  // was alone in since `mimo-v2-pro-free` lost its model.
109705
109727
  mimo: {
@@ -110155,6 +110177,17 @@ var providers = {
110155
110177
  resolve: "openrouter/meta/muse-spark-1.3-contributor",
110156
110178
  effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
110157
110179
  openRouterResolve: "openrouter/meta/muse-spark-1.3-contributor"
110180
+ },
110181
+ // Mistral's flagship (public preview, 2026-10-06), on the routers only:
110182
+ // the catalog carries no direct Mistral provider. out of the pickers
110183
+ // until a published `pullfrog` resolves the slug; see "A new alias
110184
+ // reaches the picker at merge".
110185
+ "mistral-large": {
110186
+ displayName: "Mistral Large",
110187
+ resolve: "openrouter/mistralai/mistral-large-4-0",
110188
+ effort: ["none", "high"],
110189
+ openRouterResolve: "openrouter/mistralai/mistral-large-4-0",
110190
+ hidden: true
110158
110191
  }
110159
110192
  }
110160
110193
  }),
@@ -110246,6 +110279,13 @@ var providers = {
110246
110279
  displayName: "Muse Spark",
110247
110280
  resolve: "vercel/meta/muse-spark-1.3",
110248
110281
  effort: ["minimal", "low", "medium", "high", "xhigh"]
110282
+ },
110283
+ // hidden until a published `pullfrog` resolves it, as on OpenRouter.
110284
+ "mistral-large": {
110285
+ displayName: "Mistral Large",
110286
+ resolve: "vercel/mistral/mistral-large-4",
110287
+ effort: ["none", "low", "medium", "high"],
110288
+ hidden: true
110249
110289
  }
110250
110290
  }
110251
110291
  })
@@ -111126,7 +111166,13 @@ var PROVIDER_ERROR_PATTERNS = [
111126
111166
  // …/settings/credits`) matched neither this nor `requires more credits`, so
111127
111167
  // the one run in that episode that was a genuinely empty wallet rendered with
111128
111168
  // no CTA at all while runs 1-6 rendered correctly.
111129
- { regex: /Insufficient (?:balance|credits)/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
111169
+ // `account funds` is Zen's (#1474).
111170
+ {
111171
+ regex: /Insufficient (?:balance|credits|account funds)/i,
111172
+ label: PROVIDER_BILLING_EXHAUSTED_LABEL
111173
+ },
111174
+ // OpenAI's `You have no credits remaining` (#1474).
111175
+ { regex: /no credits remaining/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
111130
111176
  { regex: /credit balance is too low/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
111131
111177
  // "spending cap" (Gemini) and "spending limit" (xAI, #1076) are the same
111132
111178
  // condition in two house styles — a configured ceiling was reached.
@@ -111247,6 +111293,9 @@ function isRouterKeylimitExhaustedError(text) {
111247
111293
  function isProviderBillingExhausted(text) {
111248
111294
  return findProviderErrorMatch(text)?.label === PROVIDER_BILLING_EXHAUSTED_LABEL;
111249
111295
  }
111296
+ function isProviderUsageLimit(text) {
111297
+ return findProviderErrorMatch(text)?.label === PROVIDER_USAGE_LIMIT_LABEL;
111298
+ }
111250
111299
  function isProviderNoRoutableEndpoints(text) {
111251
111300
  return findProviderErrorMatch(text)?.label === PROVIDER_NO_ENDPOINTS_LABEL;
111252
111301
  }
@@ -111505,6 +111554,7 @@ ${this.buffer}`;
111505
111554
  };
111506
111555
  async function spawn3(options) {
111507
111556
  const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
111557
+ options.signal?.throwIfAborted();
111508
111558
  installSignalHandler();
111509
111559
  const startTime = performance3.now();
111510
111560
  const retain = options.retain ?? "tail";
@@ -111540,17 +111590,18 @@ async function spawn3(options) {
111540
111590
  let isActivityTimedOut = false;
111541
111591
  let lastActivityTime = performance3.now();
111542
111592
  let killedAtIdleMs;
111593
+ const terminate = () => {
111594
+ if (sigkillEscalatorId) return;
111595
+ killSelf("SIGTERM");
111596
+ sigkillEscalatorId = setTimeout(() => killSelf("SIGKILL"), 5e3);
111597
+ };
111543
111598
  if (options.timeout) {
111544
111599
  timeoutId = setTimeout(() => {
111545
111600
  isTimedOut = true;
111546
- killSelf("SIGTERM");
111547
- sigkillEscalatorId = setTimeout(() => {
111548
- if (!child.killed) {
111549
- killSelf("SIGKILL");
111550
- }
111551
- }, 5e3);
111601
+ terminate();
111552
111602
  }, options.timeout);
111553
111603
  }
111604
+ options.signal?.addEventListener("abort", terminate, { once: true });
111554
111605
  if (activityTimeoutMs > 0) {
111555
111606
  log2.debug(
111556
111607
  `spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms`
@@ -111615,6 +111666,11 @@ async function spawn3(options) {
111615
111666
  if (timeoutId) clearTimeout(timeoutId);
111616
111667
  if (sigkillEscalatorId) clearTimeout(sigkillEscalatorId);
111617
111668
  if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
111669
+ options.signal?.removeEventListener("abort", terminate);
111670
+ if (options.signal?.aborted) {
111671
+ reject(options.signal.reason);
111672
+ return;
111673
+ }
111618
111674
  if (isTimedOut) {
111619
111675
  reject(
111620
111676
  new SpawnTimeoutError(`process timed out after ${options.timeout}ms`, SPAWN_TIMEOUT_CODE)
@@ -111653,6 +111709,7 @@ ${killMsg}` : killMsg;
111653
111709
  if (timeoutId) clearTimeout(timeoutId);
111654
111710
  if (sigkillEscalatorId) clearTimeout(sigkillEscalatorId);
111655
111711
  if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
111712
+ options.signal?.removeEventListener("abort", terminate);
111656
111713
  const errMsg = `[spawn] ${options.cmd}: ${error49.message}`;
111657
111714
  console.error(errMsg);
111658
111715
  const existingStderr = stderrBuffer?.toString() ?? "";
@@ -111894,17 +111951,16 @@ Report findings clearly with file:line references and quoted evidence where poss
111894
111951
  // modes.ts
111895
111952
  var PR_SUMMARY_FORMAT = `### Default format
111896
111953
 
111897
- The body has at most three parts, in this order:
111954
+ The body has at most two parts, in this order:
111898
111955
 
111899
111956
  1. **Reviewed changes preamble** \u2014 a bolded \`**Reviewed changes**\` lead-in with one sentence on what was reviewed in this run (for \`IncrementalReview\`: what changed since the prior pullfrog review), then a bullet list of the substantive changes \u2014 short bolded title, one sentence each. A reviewer should understand the full reviewed scope from this list alone. Close the preamble with the metadata comment below.
111900
111957
  2. **Cross-cutting issue sections** (zero or more) \u2014 one \`### {emoji} {what's wrong, not what to do}\` heading per concern.
111901
- 3. **\`### \u2139\uFE0F Nitpicks\`** at the very bottom, if any \u2014 a flat bullet list, no technical-details block. Only for what has no runtime effect: a stale or wrong comment or doc, a misleading name, a typo, dead code, a missing test. Never formatting or whitespace.
111902
111958
 
111903
111959
  **Inline vs. body.** Concerns that anchor to a specific line go inline (the \`comments\` parameter), even when their implications are broad. Body \`### \` sections are reserved for concerns that have **no line to anchor to** \u2014 *absence* (something the diff should have done but didn't), *sequencing* (rollout / deletion / migration order), *design decisions only the human can make*, or *scope questions the diff raises but doesn't address*. With no non-anchorable concerns, the body is just the preamble + metadata.
111904
111960
 
111905
111961
  **Severity emoji** on every \`### \` heading, and nowhere else: \u{1F6A8} critical (blocks merge \u2014 data loss, security, broken core flow) \xB7 \u26A0\uFE0F important (must address before merging) \xB7 \u2139\uFE0F informational (mergeable as-is).
111906
111962
 
111907
- **Behavior decides placement, not fix size.** A finding that changes what the code does \u2014 a wrong value or message, a crash, a race, a leak, a missed cleanup, a slow path \u2014 gets its own inline comment (or a \`### \` section if no line anchors it) at its real severity, however small the fix. Never put it in Nitpicks: before submitting, re-read each Nitpicks bullet and move any that fails this test inline.
111963
+ **Behavior decides placement, not fix size.** A finding that changes what the code does \u2014 a wrong value or message, a crash, a race, a leak, a missed cleanup, a slow path \u2014 gets its own inline comment (or a \`### \` section if no line anchors it) at its real severity, however small the fix.
111908
111964
 
111909
111965
  **Blank line between every block-level element.** GitHub's markdown parser requires one before and after HTML tags (\`<details>\`, \`<summary>\`, \`<sub>\`, \`<br/>\`) \u2014 without it GitHub treats what follows as a continuation of the HTML block and renders your markdown as literal text. This is a parser quirk, not a style preference, and it permanently breaks the posted review.
111910
111966
 
@@ -112053,8 +112109,6 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
112053
112109
 
112054
112110
  3. **triage**: orient yourself on the PR \u2014 identify *what kind of thing this is* (domain it touches, seams it crosses, external contracts it depends on, user-facing surfaces it changes). pull as much context as you need to render a confident, well-grounded review: read related files, grep for callers of changed symbols, check tests that exercise the touched paths, fetch related GitHub state. **you are the synthesizer** \u2014 never delegate understanding to subagents.
112055
112111
 
112056
- when the diff adds or changes a test, check that it can actually fail: a test that would still pass with the bug present is theatre, not coverage. the usual tell is a loose assertion standing where an exact one belongs \u2014 \`>=\` or a truthiness check over an expected value, or a snapshot that absorbs whatever it is handed. read the assertion against the behavior it claims to pin, not against whether it currently passes.
112057
-
112058
112112
  skip the deeper pass and submit a \`No new issues found.\` review per step 7 only when the diff has **no behavioral surface at all** \u2014 doc typos, whitespace/formatting, lockfile or generated-code regeneration, a mechanical rename whose only effect is import-path updates. line count is not the signal: a one-line change to auth, money, SQL, a comparison operator, a redirect, or a config default is not trivial.
112059
112113
 
112060
112114
  4. **specialist decision**: after reading the complete diff, name the questions you still cannot answer confidently yourself, and dispatch one \`${REVIEWER_AGENT_NAME}\` specialist per question. a question qualifies only when a specialist could return evidence that **changes your disposition** on the PR \u2014 generic requests for another look, extra confidence, or polish do not. most reviews need zero or one; some need several.
@@ -112078,8 +112132,6 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
112078
112132
 
112079
112133
  6. **aggregate & draft**: when specialist results land, merge findings; de-dup overlaps (two specialists catching the same issue = higher-confidence signal); trace each finding yourself before accepting it. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the PR (heuristic: if the finding's root cause lives in lines this PR added or modified, it's in scope; otherwise drop unless the PR plausibly introduced or amplified the regression), and anything not actionable. also drop **bloat-shaped findings** \u2014 proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or worse, degrades elegance to nominally improve correctness) makes the codebase worse, not better.
112080
112134
 
112081
- **Hunt for non-anchored concerns before drafting.** After collecting your anchored findings, deliberately scan for concerns that have no specific line to point at \u2014 typically: deletion / cleanup plans for code the diff replaces or shadows; rollout sequencing (what happens to in-flight state during deploy / revert?); scope questions that only the human can answer (e.g. is the legacy path going away or is this a long-term dual track?); architectural risks the diff opens up that aren't a single-line bug.
112082
-
112083
112135
  for surviving findings, draft inline comments with NEW line numbers from the diff \u2014 attach a \`<details>Technical details</details>\` block to any inline comment whose fix is non-trivial or has cross-file implications (see Inline technical details in the format below). every comment must be actionable, 2-3 sentences max in the visible part, one per root cause \u2014 name its other sites in that comment rather than repeating it. use GitHub permalink format for code references. for impact-analysis findings (stale references after rename/remove), report them in the review body ordered by severity (runtime breakage > incorrect docs > stale comments) rather than as inline comments unless they're anchored to a specific line.
112084
112136
 
112085
112137
  7. **submit**: ALWAYS submit exactly one review via \`${t2("create_pull_request_review")}\`. Do NOT call \`report_progress\` \u2014 the review is the final record and the progress comment will be cleaned up automatically.
@@ -112101,7 +112153,7 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
112101
112153
  \`approved: false\`. Body opens with \`> [!CAUTION]\\n> This PR introduces ...\`, followed by the PR summary. Include all inline comments via \`comments\`.
112102
112154
  - **must-address non-critical findings** (real consequences if shipped \u2014 incorrect behavior in non-critical paths, missing validation on user input, regressions the author should fix before merge):
112103
112155
  \`approved: false\`. Body opens with \`> [!IMPORTANT]\\n> ...\`, followed by the PR summary. Reserve this tier for findings with concrete fallout \u2014 do NOT use \`[!IMPORTANT]\` for nits, style preferences, or "consider also" suggestions. Include all inline comments via \`comments\`.
112104
- - **minor suggestions only** (low-stakes findings, or Nitpicks only):
112156
+ - **minor suggestions only** (low-stakes findings):
112105
112157
  \`approved: false\`. Body opens with \`> \u2139\uFE0F No critical issues \u2014 minor suggestions inline.\\n\\n\` followed by the PR summary. Include all inline comments via \`comments\`. Vary the wording after the emoji to fit the review (e.g. "Minor suggestions only.", "Two rough edges worth a look."), but always keep the \u2139\uFE0F prefix and keep it short.
112106
112158
  - **informational observations** (mergeable as-is, nothing actionable \u2014 e.g. prior feedback addressed cleanly, calling out something noteworthy without recommending a change):
112107
112159
  \`approved: true\`. Body opens with \`> \u2705 No new issues found.\\n\\n\` followed by the PR summary. Do NOT include inline \`comments\` \u2014 the \u2705 signals "no action needed", which contradicts an actionable anchor; if a point is concrete enough to anchor to a line, downgrade the whole review to "minor suggestions only" (\`approved: false\`) instead.
@@ -112144,8 +112196,6 @@ ${PR_SUMMARY_FORMAT}`
112144
112196
 
112145
112197
  5. **triage**: orient on the *incremental* changes \u2014 domain, seams, external contracts, user-facing surfaces. pull as much context as you need to render a confident review: read related files, grep for callers of changed symbols, check tests that exercise the touched paths. **you are the synthesizer.**
112146
112198
 
112147
- a test added or changed in this delta must be able to fail \u2014 one that would still pass with the bug present is theatre, not coverage. the tell is a loose assertion where an exact one belongs (\`>=\` or a truthiness check over an expected value, a snapshot that absorbs whatever it is handed).
112148
-
112149
112199
  skip the deeper pass and jump to step 10's non-substantive path (do NOT submit a review) only when the incremental changes have **no behavioral surface at all** \u2014 formatting, comment tweaks, import reordering, lockfile regen, a mechanical rename of import paths. line count is not the signal: a one-line change to auth, money, SQL, a comparison operator, a redirect, or a config default is not trivial.
112150
112200
 
112151
112201
  6. **specialist decision**: after covering the incremental and full diffs, name the questions about the new changes that you still cannot answer confidently yourself, and dispatch one \`${REVIEWER_AGENT_NAME}\` specialist per question. a question qualifies only when a specialist could return evidence that **changes your disposition** on the PR \u2014 generic requests for another look, extra confidence, or polish do not. most incremental reviews need zero or one, especially thread-reply re-reviews; some need several.
@@ -112169,11 +112219,9 @@ ${PR_SUMMARY_FORMAT}`
112169
112219
 
112170
112220
  8. **aggregate, draft, self-critique**: merge findings (yours + output from every specialist you dispatched); de-dup overlaps; trace each finding yourself. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the new commits, anything not actionable, and anything that re-states prior review feedback (heuristic: if the finding's root cause lives in lines the *new commits* added or modified, it's in scope; otherwise drop). also drop **bloat-shaped findings** \u2014 proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or degrades elegance to nominally improve correctness) makes the codebase worse, not better. To compute "lines the new commits added or modified": if \`incrementalDiffPath\` from step 2 is present, use it directly. Otherwise, take the prior Pullfrog review's \`commit_id\` (returned alongside each entry from \`${t2("list_pull_request_reviews")}\` in step 4) and run \`git diff <prior-review-sha>..HEAD\` to isolate the lines added since that review.
112171
112221
 
112172
- **Hunt for non-anchored concerns before drafting.** After collecting your anchored findings, deliberately scan for concerns that have no specific line to point at \u2014 typically: deletion / cleanup plans for code the new commits replace or shadow; rollout sequencing (what happens to in-flight state during deploy / revert?); scope questions that only the human can answer (e.g. is the legacy path going away or is this a long-term dual track?); architectural risks the new commits open up that aren't a single-line bug.
112173
-
112174
112222
  draft inline comments with NEW line numbers from the full PR diff \u2014 attach a \`<details>Technical details</details>\` block to any inline comment whose fix is non-trivial or has cross-file implications (see Inline technical details in the format below). every comment must be actionable, 2-3 sentences max in the visible part, one per root cause \u2014 name its other sites in that comment rather than repeating it.
112175
112223
 
112176
- 9. **build the review body**: use the same default format as Review mode (preamble + optional cross-cutting \`### \` sections + optional \`### \u2139\uFE0F Nitpicks\`) \u2014 scoped to the **incremental delta**, not the full PR. The "Reviewed changes" bullets describe what changed since the prior pullfrog review (each bullet starts with a past-tense verb, e.g. \`- Extracted shared CLI runtime into a single module\`). Do NOT include a separate "Prior review feedback" checklist \u2014 that's tracked in the rolling PR summary snapshot for the next agent run, and surfacing it in the user-facing body is noise (changes that addressed prior feedback are already covered by the Reviewed-changes bullets). In some cases you may receive a complete diff for the whole PR instead of an incremental one; when this happens, determine what changed since Pullfrog's most recent review yourself before drafting bullets.
112224
+ 9. **build the review body**: use the same default format as Review mode (preamble + optional cross-cutting \`### \` sections) \u2014 scoped to the **incremental delta**, not the full PR. The "Reviewed changes" bullets describe what changed since the prior pullfrog review (each bullet starts with a past-tense verb, e.g. \`- Extracted shared CLI runtime into a single module\`). Do NOT include a separate "Prior review feedback" checklist \u2014 that's tracked in the rolling PR summary snapshot for the next agent run, and surfacing it in the user-facing body is noise (changes that addressed prior feedback are already covered by the Reviewed-changes bullets). In some cases you may receive a complete diff for the whole PR instead of an incremental one; when this happens, determine what changed since Pullfrog's most recent review yourself before drafting bullets.
112177
112225
 
112178
112226
  10. Submit \u2014 every run must end with EXACTLY ONE of \`${t2("create_pull_request_review")}\` (substantive review) or \`${t2("report_progress")}\` (no-review acknowledgement). do NOT call \`create_issue_comment\` for review output.
112179
112227
 
@@ -112184,7 +112232,7 @@ ${PR_SUMMARY_FORMAT}`
112184
112232
  - IF NO NEW ISSUES, NON-SUBSTANTIVE CHANGES ONLY (trivial formatting, import reordering, comment tweaks): do NOT submit a review. Instead call \`${t2("report_progress")}\` with \`no_review_needed: true\` and a 1-2 sentence note explaining no review was warranted (e.g. "No new issues. Changes since last review are formatting-only."). this leaves a visible signal that the run completed.
112185
112233
  - ELSE IF NEW CRITICAL ISSUES (blocks merge \u2014 bugs, security, data loss, broken core flows): call \`${t2("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> [!CAUTION]\\n> This PR introduces ...\`, followed by the PR summary using the default format below.
112186
112234
  - ELSE IF NEW MUST-ADDRESS NON-CRITICAL FINDINGS (real consequences if shipped \u2014 incorrect behavior, missing validation, regressions the author should fix before merge): call \`${t2("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> [!IMPORTANT]\\n> ...\`, followed by the PR summary using the default format below. Do NOT use this tier for nits, style preferences, or "consider also" suggestions.
112187
- - ELSE IF NEW MINOR SUGGESTIONS ONLY (low-stakes findings, or Nitpicks only): call \`${t2("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> \u2139\uFE0F No critical issues \u2014 minor suggestions inline.\\n\\n\` (vary the wording after \u2139\uFE0F to fit the review), followed by the PR summary using the default format below.
112235
+ - ELSE IF NEW MINOR SUGGESTIONS ONLY (low-stakes findings): call \`${t2("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> \u2139\uFE0F No critical issues \u2014 minor suggestions inline.\\n\\n\` (vary the wording after \u2139\uFE0F to fit the review), followed by the PR summary using the default format below.
112188
112236
  - ELSE IF INFORMATIONAL OBSERVATIONS (mergeable as-is, but worth surfacing \u2014 e.g. prior feedback addressed cleanly, or a noteworthy positive observation): call \`${t2("create_pull_request_review")}\` with \`approved: true\`, NO inline comments, and the review body. body opens with \`> \u2705 No new issues found.\\n\\n\` (or similar friendly green opener), followed by the PR summary using the default format below. If a point is concrete enough to anchor to a line, downgrade the whole review to "minor suggestions only" (\`approved: false\`) instead \u2014 the \u2705 signals "no action needed", which contradicts an actionable anchor.
112189
112237
  - ELSE IF NO NEW ISSUES, SUBSTANTIVE CHANGES (new functionality, behavior changes, or fixes to prior review feedback): call \`${t2("create_pull_request_review")}\` to create a PR review. If all previous reviews have been properly addressed and no new issues were discovered, set \`approved: true\`. body opens with \`> \u2705 No new issues found.\\n\\n\`, followed by the PR summary using the default format below.
112190
112238
 
@@ -113401,186 +113449,13 @@ import { performance as performance6 } from "node:perf_hooks";
113401
113449
 
113402
113450
  // utils/codexHome.ts
113403
113451
  import { execFileSync as execFileSync4 } from "node:child_process";
113404
- import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync5 } from "node:fs";
113452
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, mkdtempSync as mkdtempSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "node:fs";
113405
113453
  import { homedir as homedir2, userInfo } from "node:os";
113406
113454
  import { join as join6 } from "node:path";
113407
- var CODEX_AUTH_ENV = "CODEX_AUTH_JSON";
113408
- var XAI_AUTH_ENV = "GROK_AUTH_JSON";
113409
- var installedSubscriptionPaths = /* @__PURE__ */ new Map();
113410
- function clearInstalledSubscription(name) {
113411
- const path4 = installedSubscriptionPaths.get(name);
113412
- if (!path4 || !existsSync5(path4)) return;
113413
- const value2 = JSON.parse(readFileSync3(path4, "utf8"));
113414
- if (!value2 || typeof value2 !== "object") return;
113415
- if (name === "CODEX_AUTH_JSON" && "openai" in value2) delete value2.openai;
113416
- if (name === "GROK_AUTH_JSON" && "xai" in value2) delete value2.xai;
113417
- writeFileSync5(path4, `${JSON.stringify(value2)}
113418
- `, { mode: 384 });
113419
- installedSubscriptionPaths.delete(name);
113420
- }
113421
- var PULLFROG_DATA_DIR = "/var/lib/pullfrog";
113422
- function isRejectedChain(body) {
113423
- if (!body.refresh_rejected_at) return false;
113424
- log2.warning(
113425
- `\xBB ${CODEX_AUTH_ENV} was rejected by OpenAI at ${body.refresh_rejected_at} and cannot be refreshed \u2014 re-run \`npx pullfrog auth codex\`. skipping it for this run.`
113426
- );
113427
- return true;
113428
- }
113429
- function installCodexAuth() {
113430
- const raw2 = process.env[CODEX_AUTH_ENV];
113431
- if (!raw2) return null;
113432
- const body = parseCodexAuthBody(raw2);
113433
- if (!body) {
113434
- log2.warning(`\xBB ${CODEX_AUTH_ENV} present but malformed; ignoring`);
113435
- return null;
113436
- }
113437
- if (isRejectedChain(body)) return null;
113438
- const expiresMs = decodeJwtExpMs(body.tokens.access_token) ?? 0;
113439
- const xdgDataHome = resolveDataHome();
113440
- if (!xdgDataHome) return null;
113441
- const opencodeDir = join6(xdgDataHome, "opencode");
113442
- const authPath = join6(opencodeDir, "auth.json");
113443
- writeOpenCodeAuthEntry({
113444
- opencodeDir,
113445
- authPath,
113446
- provider: "openai",
113447
- entry: {
113448
- type: "oauth",
113449
- refresh: body.tokens.refresh_token,
113450
- access: body.tokens.access_token,
113451
- expires: expiresMs,
113452
- ...body.tokens.account_id ? { accountId: body.tokens.account_id } : {}
113453
- }
113454
- });
113455
- process.env.XDG_DATA_HOME = xdgDataHome;
113456
- log2.info(`\xBB installed Codex auth at ${authPath}`);
113457
- installedSubscriptionPaths.set(CODEX_AUTH_ENV, authPath);
113458
- return {
113459
- authPath,
113460
- xdgDataHome,
113461
- originalRefresh: body.tokens.refresh_token,
113462
- originalIdToken: body.tokens.id_token
113463
- };
113464
- }
113465
- function writeOpenCodeAuthEntry(params) {
113466
- let existing = {};
113467
- if (existsSync5(params.authPath)) {
113468
- try {
113469
- const parsed2 = JSON.parse(readFileSync3(params.authPath, "utf8"));
113470
- if (parsed2 && typeof parsed2 === "object") existing = parsed2;
113471
- } catch {
113472
- log2.warning(`\xBB ${params.authPath} was unreadable; rewriting it`);
113473
- }
113474
- }
113475
- const merged = { ...existing, [params.provider]: params.entry };
113476
- mkdirSync5(params.opencodeDir, { recursive: true });
113477
- writeFileSync5(params.authPath, `${JSON.stringify(merged, null, 2)}
113478
- `, { mode: 384 });
113479
- }
113480
- function installXaiAuth() {
113481
- const raw2 = process.env[XAI_AUTH_ENV];
113482
- if (!raw2) return null;
113483
- const body = parseXaiAuthBody(raw2);
113484
- if (!body) {
113485
- log2.warning(`\xBB ${XAI_AUTH_ENV} present but malformed; ignoring`);
113486
- return null;
113487
- }
113488
- if (isXaiRejectedChain(body)) return null;
113489
- const xdgDataHome = resolveDataHome();
113490
- if (!xdgDataHome) return null;
113491
- const opencodeDir = join6(xdgDataHome, "opencode");
113492
- const authPath = join6(opencodeDir, "auth.json");
113493
- writeOpenCodeAuthEntry({
113494
- opencodeDir,
113495
- authPath,
113496
- provider: "xai",
113497
- // opencode refreshes when `expires` is inside its skew window, and falls
113498
- // back to the JWT's own exp when the stored value is absent. Decoding it
113499
- // here means the first request uses the token we already hold instead of
113500
- // burning a rotation on startup.
113501
- entry: {
113502
- type: "oauth",
113503
- refresh: body.tokens.refresh_token,
113504
- access: body.tokens.access_token,
113505
- expires: decodeJwtExpMs(body.tokens.access_token) ?? 0
113506
- }
113507
- });
113508
- process.env.XDG_DATA_HOME = xdgDataHome;
113509
- log2.info(`\xBB installed Grok auth at ${authPath}`);
113510
- installedSubscriptionPaths.set(XAI_AUTH_ENV, authPath);
113511
- return { authPath, xdgDataHome, originalRefresh: body.tokens.refresh_token };
113512
- }
113513
- function isXaiRejectedChain(body) {
113514
- if (!body.refresh_rejected_at) return false;
113515
- log2.warning(
113516
- `\xBB ${XAI_AUTH_ENV} was rejected by xAI at ${body.refresh_rejected_at} and cannot be refreshed \u2014 re-run \`npx pullfrog auth grok\`. skipping it for this run.`
113517
- );
113518
- return true;
113519
- }
113520
- function installCodexHome() {
113521
- const raw2 = process.env[CODEX_AUTH_ENV];
113522
- if (!raw2) return null;
113523
- const body = parseCodexAuthBody(raw2);
113524
- if (!body) {
113525
- log2.warning(`\xBB ${CODEX_AUTH_ENV} present but malformed; ignoring`);
113526
- return null;
113527
- }
113528
- if (isRejectedChain(body)) return null;
113529
- if (!body.tokens.id_token) {
113530
- log2.warning(
113531
- `\xBB ${CODEX_AUTH_ENV} carries no id_token \u2014 the codex CLI cannot load it. re-run \`npx pullfrog auth codex\` to mint a complete credential.`
113532
- );
113533
- return null;
113534
- }
113535
- const root = resolveDataHome();
113536
- if (!root) return null;
113537
- const codexHome = join6(root, "codex");
113538
- const authPath = join6(codexHome, "auth.json");
113539
- mkdirSync5(codexHome, { recursive: true });
113540
- writeFileSync5(authPath, stringifyCodexAuthBody(body), { mode: 384 });
113541
- log2.info(`\xBB installed Codex auth at ${authPath}`);
113542
- return { codexHome, authPath, originalRefresh: body.tokens.refresh_token };
113543
- }
113544
- var dataHome;
113545
- var unhostedSubscriptions = /* @__PURE__ */ new Set();
113546
- function canInstallSubscription(name) {
113547
- return name !== CODEX_AUTH_ENV && name !== XAI_AUTH_ENV || resolveDataHome() !== null;
113548
- }
113549
- function resolveDataHome() {
113550
- if (process.env.CI !== "true") return join6(homedir2(), ".local", "share");
113551
- if (dataHome === void 0) dataHome = bootstrapPullfrogDataDir();
113552
- return dataHome;
113553
- }
113554
- function bootstrapPullfrogDataDir() {
113555
- const user = userInfo().username;
113556
- let primaryGroup;
113557
- try {
113558
- primaryGroup = execFileSync4("id", ["-gn", user], { stdio: "pipe", encoding: "utf-8" }).trim();
113559
- } catch {
113560
- primaryGroup = user;
113561
- }
113562
- try {
113563
- execFileSync4("sudo", ["-n", "mkdir", "-p", PULLFROG_DATA_DIR], { stdio: "pipe" });
113564
- execFileSync4("sudo", ["-n", "chown", `${user}:${primaryGroup}`, PULLFROG_DATA_DIR], {
113565
- stdio: "pipe"
113566
- });
113567
- execFileSync4("sudo", ["-n", "chmod", "700", PULLFROG_DATA_DIR], { stdio: "pipe" });
113568
- } catch (err) {
113569
- log2.warning(
113570
- `\xBB subscription auth skipped: could not create ${PULLFROG_DATA_DIR} (${err instanceof Error ? err.message : String(err)}). it must live outside the agent sandbox's reach, which needs passwordless sudo on this runner; runs on a model that needs the subscription will report no key.`
113571
- );
113572
- for (const name of [CODEX_AUTH_ENV, XAI_AUTH_ENV]) {
113573
- if (process.env[name]) unhostedSubscriptions.add(name);
113574
- delete process.env[name];
113575
- }
113576
- return null;
113577
- }
113578
- return PULLFROG_DATA_DIR;
113579
- }
113580
113455
 
113581
113456
  // utils/oauthWriteback.ts
113582
113457
  var core2 = __toESM(require_core(), 1);
113583
- import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:fs";
113458
+ import { existsSync as existsSync5, readFileSync as readFileSync3, rmSync as rmSync3 } from "node:fs";
113584
113459
 
113585
113460
  // utils/apiUrl.ts
113586
113461
  function isLocalUrl(url4) {
@@ -113712,7 +113587,15 @@ function detectXaiRefresh(params) {
113712
113587
 
113713
113588
  // utils/oauthWriteback.ts
113714
113589
  var OAUTH_WRITEBACK_STATE = "oauth_writeback";
113590
+ var SUBSCRIPTION_DATA_DIR_STATE = "subscription_data_dir";
113715
113591
  async function runOAuthWriteback() {
113592
+ await writeBackEntries();
113593
+ const dataDir = core2.getState(SUBSCRIPTION_DATA_DIR_STATE);
113594
+ if (!dataDir) return;
113595
+ rmSync3(dataDir, { recursive: true, force: true });
113596
+ core2.info(`oauth post-hook: removed ${dataDir}`);
113597
+ }
113598
+ async function writeBackEntries() {
113716
113599
  const raw2 = core2.getState(OAUTH_WRITEBACK_STATE);
113717
113600
  if (!raw2) {
113718
113601
  core2.info("oauth post-hook: no writeback state \u2014 skipping");
@@ -113738,13 +113621,13 @@ async function writeBackEntry(apiToken, entry) {
113738
113621
  core2.warning("oauth post-hook: incomplete writeback entry \u2014 skipping");
113739
113622
  return;
113740
113623
  }
113741
- if (!existsSync6(entry.authPath)) {
113624
+ if (!existsSync5(entry.authPath)) {
113742
113625
  core2.info(`oauth post-hook: ${entry.authPath} not found \u2014 nothing to write back`);
113743
113626
  return;
113744
113627
  }
113745
113628
  let authFileContent;
113746
113629
  try {
113747
- authFileContent = readFileSync4(entry.authPath, "utf8");
113630
+ authFileContent = readFileSync3(entry.authPath, "utf8");
113748
113631
  } catch (err) {
113749
113632
  core2.warning(`oauth post-hook: cannot read ${entry.authPath} \u2014 ${err}`);
113750
113633
  return;
@@ -113801,6 +113684,188 @@ function setSecretOutput(name, value2) {
113801
113684
  if (isGitHubActions) core3.setOutput(name, value2);
113802
113685
  }
113803
113686
 
113687
+ // utils/codexHome.ts
113688
+ var CODEX_AUTH_ENV = "CODEX_AUTH_JSON";
113689
+ var XAI_AUTH_ENV = "GROK_AUTH_JSON";
113690
+ var installedSubscriptionPaths = /* @__PURE__ */ new Map();
113691
+ var installedDataHome;
113692
+ function getSubscriptionDataHome() {
113693
+ return installedDataHome;
113694
+ }
113695
+ function clearInstalledSubscription(name) {
113696
+ const path4 = installedSubscriptionPaths.get(name);
113697
+ if (!path4 || !existsSync6(path4)) return;
113698
+ const value2 = JSON.parse(readFileSync4(path4, "utf8"));
113699
+ if (!value2 || typeof value2 !== "object") return;
113700
+ if (name === "CODEX_AUTH_JSON" && "openai" in value2) delete value2.openai;
113701
+ if (name === "GROK_AUTH_JSON" && "xai" in value2) delete value2.xai;
113702
+ writeFileSync5(path4, `${JSON.stringify(value2)}
113703
+ `, { mode: 384 });
113704
+ installedSubscriptionPaths.delete(name);
113705
+ }
113706
+ var PULLFROG_DATA_DIR = "/var/lib/pullfrog";
113707
+ function isRejectedChain(body) {
113708
+ if (!body.refresh_rejected_at) return false;
113709
+ log2.warning(
113710
+ `\xBB ${CODEX_AUTH_ENV} was rejected by OpenAI at ${body.refresh_rejected_at} and cannot be refreshed \u2014 re-run \`npx pullfrog auth codex\`. skipping it for this run.`
113711
+ );
113712
+ return true;
113713
+ }
113714
+ function installCodexAuth() {
113715
+ const raw2 = process.env[CODEX_AUTH_ENV];
113716
+ if (!raw2) return null;
113717
+ const body = parseCodexAuthBody(raw2);
113718
+ if (!body) {
113719
+ log2.warning(`\xBB ${CODEX_AUTH_ENV} present but malformed; ignoring`);
113720
+ return null;
113721
+ }
113722
+ if (isRejectedChain(body)) return null;
113723
+ const expiresMs = decodeJwtExpMs(body.tokens.access_token) ?? 0;
113724
+ const xdgDataHome = resolveDataHome();
113725
+ if (!xdgDataHome) return null;
113726
+ const opencodeDir = join6(xdgDataHome, "opencode");
113727
+ const authPath = join6(opencodeDir, "auth.json");
113728
+ writeOpenCodeAuthEntry({
113729
+ opencodeDir,
113730
+ authPath,
113731
+ provider: "openai",
113732
+ entry: {
113733
+ type: "oauth",
113734
+ refresh: body.tokens.refresh_token,
113735
+ access: body.tokens.access_token,
113736
+ expires: expiresMs,
113737
+ ...body.tokens.account_id ? { accountId: body.tokens.account_id } : {}
113738
+ }
113739
+ });
113740
+ installedDataHome = xdgDataHome;
113741
+ log2.info(`\xBB installed Codex auth at ${authPath}`);
113742
+ installedSubscriptionPaths.set(CODEX_AUTH_ENV, authPath);
113743
+ return {
113744
+ authPath,
113745
+ originalRefresh: body.tokens.refresh_token,
113746
+ originalIdToken: body.tokens.id_token
113747
+ };
113748
+ }
113749
+ function writeOpenCodeAuthEntry(params) {
113750
+ let existing = {};
113751
+ if (existsSync6(params.authPath)) {
113752
+ try {
113753
+ const parsed2 = JSON.parse(readFileSync4(params.authPath, "utf8"));
113754
+ if (parsed2 && typeof parsed2 === "object") existing = parsed2;
113755
+ } catch {
113756
+ log2.warning(`\xBB ${params.authPath} was unreadable; rewriting it`);
113757
+ }
113758
+ }
113759
+ const merged = { ...existing, [params.provider]: params.entry };
113760
+ mkdirSync5(params.opencodeDir, { recursive: true });
113761
+ writeFileSync5(params.authPath, `${JSON.stringify(merged, null, 2)}
113762
+ `, { mode: 384 });
113763
+ }
113764
+ function installXaiAuth() {
113765
+ const raw2 = process.env[XAI_AUTH_ENV];
113766
+ if (!raw2) return null;
113767
+ const body = parseXaiAuthBody(raw2);
113768
+ if (!body) {
113769
+ log2.warning(`\xBB ${XAI_AUTH_ENV} present but malformed; ignoring`);
113770
+ return null;
113771
+ }
113772
+ if (isXaiRejectedChain(body)) return null;
113773
+ const xdgDataHome = resolveDataHome();
113774
+ if (!xdgDataHome) return null;
113775
+ const opencodeDir = join6(xdgDataHome, "opencode");
113776
+ const authPath = join6(opencodeDir, "auth.json");
113777
+ writeOpenCodeAuthEntry({
113778
+ opencodeDir,
113779
+ authPath,
113780
+ provider: "xai",
113781
+ // opencode refreshes when `expires` is inside its skew window, and falls
113782
+ // back to the JWT's own exp when the stored value is absent. Decoding it
113783
+ // here means the first request uses the token we already hold instead of
113784
+ // burning a rotation on startup.
113785
+ entry: {
113786
+ type: "oauth",
113787
+ refresh: body.tokens.refresh_token,
113788
+ access: body.tokens.access_token,
113789
+ expires: decodeJwtExpMs(body.tokens.access_token) ?? 0
113790
+ }
113791
+ });
113792
+ installedDataHome = xdgDataHome;
113793
+ log2.info(`\xBB installed Grok auth at ${authPath}`);
113794
+ installedSubscriptionPaths.set(XAI_AUTH_ENV, authPath);
113795
+ return { authPath, originalRefresh: body.tokens.refresh_token };
113796
+ }
113797
+ function isXaiRejectedChain(body) {
113798
+ if (!body.refresh_rejected_at) return false;
113799
+ log2.warning(
113800
+ `\xBB ${XAI_AUTH_ENV} was rejected by xAI at ${body.refresh_rejected_at} and cannot be refreshed \u2014 re-run \`npx pullfrog auth grok\`. skipping it for this run.`
113801
+ );
113802
+ return true;
113803
+ }
113804
+ function installCodexHome() {
113805
+ const raw2 = process.env[CODEX_AUTH_ENV];
113806
+ if (!raw2) return null;
113807
+ const body = parseCodexAuthBody(raw2);
113808
+ if (!body) {
113809
+ log2.warning(`\xBB ${CODEX_AUTH_ENV} present but malformed; ignoring`);
113810
+ return null;
113811
+ }
113812
+ if (isRejectedChain(body)) return null;
113813
+ if (!body.tokens.id_token) {
113814
+ log2.warning(
113815
+ `\xBB ${CODEX_AUTH_ENV} carries no id_token \u2014 the codex CLI cannot load it. re-run \`npx pullfrog auth codex\` to mint a complete credential.`
113816
+ );
113817
+ return null;
113818
+ }
113819
+ const root = resolveDataHome();
113820
+ if (!root) return null;
113821
+ const codexHome = join6(root, "codex");
113822
+ const authPath = join6(codexHome, "auth.json");
113823
+ mkdirSync5(codexHome, { recursive: true });
113824
+ writeFileSync5(authPath, stringifyCodexAuthBody(body), { mode: 384 });
113825
+ log2.info(`\xBB installed Codex auth at ${authPath}`);
113826
+ return { codexHome, authPath, originalRefresh: body.tokens.refresh_token };
113827
+ }
113828
+ var dataHome;
113829
+ var unhostedSubscriptions = /* @__PURE__ */ new Set();
113830
+ function canInstallSubscription(name) {
113831
+ return name !== CODEX_AUTH_ENV && name !== XAI_AUTH_ENV || resolveDataHome() !== null;
113832
+ }
113833
+ function resolveDataHome() {
113834
+ if (process.env.CI !== "true") return join6(homedir2(), ".local", "share");
113835
+ if (dataHome === void 0) dataHome = bootstrapPullfrogDataDir();
113836
+ return dataHome;
113837
+ }
113838
+ function bootstrapPullfrogDataDir() {
113839
+ const user = userInfo().username;
113840
+ let primaryGroup;
113841
+ try {
113842
+ primaryGroup = execFileSync4("id", ["-gn", user], { stdio: "pipe", encoding: "utf-8" }).trim();
113843
+ } catch {
113844
+ primaryGroup = user;
113845
+ }
113846
+ try {
113847
+ execFileSync4("sudo", ["-n", "mkdir", "-p", PULLFROG_DATA_DIR], { stdio: "pipe" });
113848
+ execFileSync4("sudo", ["-n", "chown", `${user}:${primaryGroup}`, PULLFROG_DATA_DIR], {
113849
+ stdio: "pipe"
113850
+ });
113851
+ execFileSync4("sudo", ["-n", "chmod", "700", PULLFROG_DATA_DIR], { stdio: "pipe" });
113852
+ } catch (err) {
113853
+ log2.warning(
113854
+ `\xBB subscription auth skipped: could not create ${PULLFROG_DATA_DIR} (${err instanceof Error ? err.message : String(err)}). it must live outside the agent sandbox's reach, which needs passwordless sudo on this runner; runs on a model that needs the subscription will report no key.`
113855
+ );
113856
+ for (const name of [CODEX_AUTH_ENV, XAI_AUTH_ENV]) {
113857
+ if (process.env[name]) unhostedSubscriptions.add(name);
113858
+ delete process.env[name];
113859
+ }
113860
+ return null;
113861
+ }
113862
+ const runDir = mkdtempSync2(join6(PULLFROG_DATA_DIR, "run-"));
113863
+ if (process.env.RUNNER_ENVIRONMENT !== "github-hosted") {
113864
+ saveSecretState(SUBSCRIPTION_DATA_DIR_STATE, runDir);
113865
+ }
113866
+ return runDir;
113867
+ }
113868
+
113804
113869
  // utils/secrets.ts
113805
113870
  var SENSITIVE_PATTERNS = [
113806
113871
  /_KEY$/i,
@@ -114447,9 +114512,8 @@ var codex = agent({
114447
114512
  initialUsage: initial.usage,
114448
114513
  reflectionPrompt: buildReflectionPrompt(ctx.toolState),
114449
114514
  // a resume needs the thread codex recorded. without one there is nothing
114450
- // to continue, and `resume --last` would be worse than not resuming: it
114451
- // picks the newest recorded session in this cwd, which on a self-hosted
114452
- // runner with a shared CODEX_HOME is somebody else's run.
114515
+ // to continue, and `resume --last` could pick another run's session off CI,
114516
+ // where CODEX_HOME is the shared ~/.local/share/codex (in CI it is per run).
114453
114517
  canResume: (r2) => r2.threadId !== void 0,
114454
114518
  resume: async (c2) => runCodex2({
114455
114519
  ...runnerArgs,
@@ -114476,7 +114540,7 @@ var codex = agent({
114476
114540
  import { spawn as nodeSpawn2 } from "node:child_process";
114477
114541
  import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync7 } from "node:fs";
114478
114542
  import { totalmem } from "node:os";
114479
- import { join as join8 } from "node:path";
114543
+ import { join as join9 } from "node:path";
114480
114544
  import { performance as performance7 } from "node:perf_hooks";
114481
114545
 
114482
114546
  // node_modules/.pnpm/@opencode-ai+sdk@1.18.29/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js
@@ -119885,200 +119949,27 @@ var import_cross_spawn = __toESM(require_cross_spawn(), 1);
119885
119949
  // agents/opencode.ts
119886
119950
  var import_undici = __toESM(require_undici2(), 1);
119887
119951
 
119888
- // utils/worktree.ts
119889
- async function dirtyTrackedPaths(params = {}) {
119890
- const result = await spawn3({
119891
- cmd: "git",
119892
- args: ["diff", "--name-only", "HEAD"],
119893
- env: process.env,
119894
- cwd: params.cwd ?? process.cwd(),
119895
- activityTimeout: 0
119896
- });
119897
- if (result.exitCode !== 0) {
119898
- throw new Error(
119899
- `git diff --name-only HEAD failed (exit ${result.exitCode}): ${result.stderr.trim() || "(no stderr)"}`
119900
- );
119901
- }
119902
- return new Set(result.stdout.split("\n").filter(Boolean));
119903
- }
119904
- async function restoreDirtiedSince(params) {
119905
- const dirtied = [...await dirtyTrackedPaths({ cwd: params.cwd ?? process.cwd() })].filter(
119906
- (path4) => !params.before.has(path4)
119907
- );
119908
- if (dirtied.length === 0) return;
119909
- const result = await spawn3({
119910
- cmd: "git",
119911
- args: ["restore", "--staged", "--worktree", "--", ...dirtied],
119912
- env: process.env,
119913
- cwd: params.cwd ?? process.cwd(),
119914
- activityTimeout: 0
119915
- });
119916
- if (result.exitCode !== 0) {
119917
- log2.warning(
119918
- `\xBB failed to restore ${dirtied.length} tracked file(s) modified by ${params.actor}: ${result.stderr.trim() || "(no stderr)"}`
119919
- );
119920
- return;
119921
- }
119922
- log2.info(
119923
- `\xBB restored ${dirtied.length} tracked file(s) modified by ${params.actor}: ${dirtied.join(", ")}`
119924
- );
119925
- }
119926
-
119927
- // agents/opencodePlugin.ts
119928
- var PULLFROG_BUS_EVENT_TYPE = "pullfrog_bus_event";
119929
- var PULLFROG_OPENCODE_GATE_PLUGIN_FILENAME = "pullfrog-subagent-gate.ts";
119930
- var PULLFROG_OPENCODE_PLUGIN_SOURCE = `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
119931
- // surfaces opencode subagent activity that the CLI's run-loop discards. see
119932
- // action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
119933
- // inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
119934
- // the user's working tree. the subagent gate ships as a separate plugin
119935
- // (pullfrog-subagent-gate.ts).
119936
-
119937
- const PULLFROG_BUS_EVENT_TYPE = ${JSON.stringify(PULLFROG_BUS_EVENT_TYPE)};
119938
-
119939
- // orchestratorSessionID locks to the first sessionID we observe on the bus
119940
- // (\`message.part.updated\`). opencode's run command creates exactly one
119941
- // top-level session before any subagent is dispatched, and the orchestrator
119942
- // emits at least one event before any subagent does \u2014 so first-seen-wins
119943
- // captures the orchestrator; every subagent part has a non-matching
119944
- // sessionID.
119945
- let orchestratorSessionID: string | undefined;
119946
-
119947
- function isOrchestratorTaskDispatch(part: {
119948
- type?: string;
119949
- tool?: string;
119950
- state?: { status?: string };
119951
- } | undefined): boolean {
119952
- if (!part || part.type !== "tool") return false;
119953
- if (part.tool !== "task") return false;
119954
- // only forward at status="running" (not "pending"). at pending the
119955
- // state.input is still {} \u2014 the orchestrator has emitted the part shell
119956
- // but the LLM hasn't filled in description/subagent_type/prompt yet. by
119957
- // running, input is populated and recordTaskDispatch can derive the lens
119958
- // label correctly.
119959
- return part.state?.status === "running";
119960
- }
119961
-
119962
- export default async function pullfrogEventsPlugin() {
119963
- return {
119964
- event: async (input: {
119965
- event: {
119966
- type: string;
119967
- properties?: {
119968
- part?: {
119969
- sessionID?: string;
119970
- type?: string;
119971
- tool?: string;
119972
- state?: { status?: string };
119973
- };
119974
- };
119975
- };
119976
- }) => {
119977
- const event = input?.event;
119978
- if (!event || typeof event !== "object") return;
119979
- if (event.type !== "message.part.updated") return;
119980
- const part = event.properties?.part;
119981
- const sessionID = part?.sessionID;
119982
- if (typeof sessionID !== "string" || sessionID.length === 0) return;
119983
- if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
119984
-
119985
- if (sessionID === orchestratorSessionID) {
119986
- // skip orchestrator events EXCEPT early task dispatches.
119987
- if (!isOrchestratorTaskDispatch(part)) return;
119988
- }
119989
-
119990
- try {
119991
- const line = JSON.stringify({
119992
- type: PULLFROG_BUS_EVENT_TYPE,
119993
- bus_event: event,
119994
- });
119995
- process.stdout.write(line + "\\n");
119996
- } catch {
119997
- // a circular reference or BigInt etc. would throw; swallow rather
119998
- // than letting a single bad event take down the plugin.
119999
- }
120000
- },
120001
- };
120002
- }
120003
- `;
120004
- function buildOpencodeSubagentGateSource(deniedTools) {
120005
- return `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
120006
- // hard-blocks state-mutating MCP tool calls from opencode subagents (PR
120007
- // following pullfrog/app#796 \u2014 see action/agents/subagentToolGates.ts). see
120008
- // action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
120009
- // inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
120010
- // the user's working tree.
120011
-
120012
- // embedded at template-render time so the plugin source has no imports. the
120013
- // denied set is derived from each tool's \`mutates\` flag \u2014 see
120014
- // action/agents/subagentToolGates.ts.
120015
- const SUBAGENT_DENIED_TOOLS = new Set(${JSON.stringify(deniedTools)});
120016
-
120017
- // opencode presents MCP tools as \`pullfrog_<bare>\`; strip that to compare
120018
- // against the canonical bare names in SUBAGENT_DENIED_TOOLS. native tools
120019
- // (read, write, bash, task, etc.) lack the prefix and never match the deny
120020
- // list anyway.
120021
- function stripPullfrogPrefix(toolName: string): string {
120022
- if (toolName.startsWith("pullfrog_")) return toolName.slice("pullfrog_".length);
120023
- return toolName;
120024
- }
120025
-
120026
- // orchestratorSessionID locks to the first sessionID we observe on
120027
- // \`tool.execute.before\`. the orchestrator must invoke a tool (e.g. its
120028
- // \`task\` dispatch) before any subagent session exists, so first-seen-wins
120029
- // always captures the orchestrator; every subagent call therefore has a
120030
- // non-matching sessionID and is subject to the deny check.
120031
- let orchestratorSessionID: string | undefined;
120032
-
120033
- export default async function pullfrogSubagentGatePlugin() {
120034
- return {
120035
- "tool.execute.before": async (
120036
- input: { tool?: string; sessionID?: string; callID?: string },
120037
- _output: { args?: Record<string, unknown> }
120038
- ) => {
120039
- // input: { tool, sessionID, callID } \u2014 confirmed against
120040
- // anomalyco/opencode packages/opencode/src/session/tools.ts which
120041
- // calls plugin.trigger("tool.execute.before", { tool, sessionID,
120042
- // callID }, { args }). throwing here surfaces as a tool error to the
120043
- // model (Effect's run.promise propagates the rejection through the
120044
- // AI SDK's tool-runtime), so the subagent sees a denial and can
120045
- // pick a different action.
120046
- const sessionID = typeof input?.sessionID === "string" ? input.sessionID : "";
120047
- const tool = typeof input?.tool === "string" ? input.tool : "";
120048
- // above the sessionID check: the bash block must never depend on a
120049
- // session id being present.
120050
- if (tool === "bash") {
120051
- throw new Error("the native bash tool is disabled in Pullfrog runs.");
120052
- }
120053
- if (!sessionID || !tool) return;
120054
- if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
120055
- if (sessionID === orchestratorSessionID) return;
120056
- const bare = stripPullfrogPrefix(tool);
120057
- if (!SUBAGENT_DENIED_TOOLS.has(bare)) return;
120058
- throw new Error(
120059
- "subagent attempted to call denied tool '" + bare + "'. " +
120060
- "subagents share the orchestrator's in-process working tree and toolState; " +
120061
- "state-changing MCP tools (checkout_pr, push_branch, create_pull_request_review, " +
120062
- "report_progress, etc.) are reserved for the orchestrator. " +
120063
- "report findings back to the orchestrator and let it perform the mutation."
120064
- );
120065
- },
120066
- };
120067
- }
120068
- `;
120069
- }
120070
-
120071
119952
  // utils/openCodeModels.ts
120072
119953
  import { spawnSync as spawnSync4 } from "node:child_process";
119954
+ import { join as join8 } from "node:path";
120073
119955
  var baseline;
120074
119956
  var authorized;
120075
119957
  var failure;
120076
119958
  var ANSI_PATTERN = /\[[0-9;]*m/g;
120077
- function readModels(cliPath) {
120078
- const result = spawnSync4(cliPath, ["models"], {
119959
+ function openCodeHomeEnv(tmpdir4) {
119960
+ return {
119961
+ HOME: tmpdir4,
119962
+ XDG_CONFIG_HOME: join8(tmpdir4, ".config"),
119963
+ XDG_DATA_HOME: getSubscriptionDataHome() ?? join8(tmpdir4, ".local", "share"),
119964
+ XDG_CACHE_HOME: join8(tmpdir4, ".cache"),
119965
+ XDG_STATE_HOME: join8(tmpdir4, ".local", "state")
119966
+ };
119967
+ }
119968
+ function readModels(params) {
119969
+ const result = spawnSync4(params.cliPath, ["models"], {
120079
119970
  encoding: "utf-8",
120080
119971
  timeout: 3e4,
120081
- env: process.env,
119972
+ env: { ...process.env, ...openCodeHomeEnv(params.tmpdir) },
120082
119973
  stdio: ["ignore", "pipe", "pipe"]
120083
119974
  });
120084
119975
  if (result.status !== 0) {
@@ -120095,12 +119986,12 @@ function readModels(cliPath) {
120095
119986
  function getModelsFailure() {
120096
119987
  return failure;
120097
119988
  }
120098
- function captureBaselineModels(cliPath) {
120099
- baseline = readModels(cliPath);
119989
+ function captureBaselineModels(params) {
119990
+ baseline = readModels(params);
120100
119991
  log2.debug(`\xBB opencode baseline: ${baseline.size} models`);
120101
119992
  }
120102
- function captureAuthorizedModels(cliPath) {
120103
- authorized = readModels(cliPath);
119993
+ function captureAuthorizedModels(params) {
119994
+ authorized = readModels(params);
120104
119995
  const base = baseline;
120105
119996
  if (base) {
120106
119997
  const diff = [...authorized].filter((m) => !base.has(m));
@@ -120120,6 +120011,190 @@ function getAuthorizedModels() {
120120
120011
  return authorized;
120121
120012
  }
120122
120013
 
120014
+ // utils/worktree.ts
120015
+ async function dirtyTrackedPaths(params = {}) {
120016
+ const result = await spawn3({
120017
+ cmd: "git",
120018
+ args: ["diff", "--name-only", "HEAD"],
120019
+ env: process.env,
120020
+ cwd: params.cwd ?? process.cwd(),
120021
+ activityTimeout: 0
120022
+ });
120023
+ if (result.exitCode !== 0) {
120024
+ throw new Error(
120025
+ `git diff --name-only HEAD failed (exit ${result.exitCode}): ${result.stderr.trim() || "(no stderr)"}`
120026
+ );
120027
+ }
120028
+ return new Set(result.stdout.split("\n").filter(Boolean));
120029
+ }
120030
+ async function restoreDirtiedSince(params) {
120031
+ const dirtied = [...await dirtyTrackedPaths({ cwd: params.cwd ?? process.cwd() })].filter(
120032
+ (path4) => !params.before.has(path4)
120033
+ );
120034
+ if (dirtied.length === 0) return;
120035
+ const result = await spawn3({
120036
+ cmd: "git",
120037
+ args: ["restore", "--staged", "--worktree", "--", ...dirtied],
120038
+ env: process.env,
120039
+ cwd: params.cwd ?? process.cwd(),
120040
+ activityTimeout: 0
120041
+ });
120042
+ if (result.exitCode !== 0) {
120043
+ log2.warning(
120044
+ `\xBB failed to restore ${dirtied.length} tracked file(s) modified by ${params.actor}: ${result.stderr.trim() || "(no stderr)"}`
120045
+ );
120046
+ return;
120047
+ }
120048
+ log2.info(
120049
+ `\xBB restored ${dirtied.length} tracked file(s) modified by ${params.actor}: ${dirtied.join(", ")}`
120050
+ );
120051
+ }
120052
+
120053
+ // agents/opencodePlugin.ts
120054
+ var PULLFROG_BUS_EVENT_TYPE = "pullfrog_bus_event";
120055
+ var PULLFROG_OPENCODE_GATE_PLUGIN_FILENAME = "pullfrog-subagent-gate.ts";
120056
+ var PULLFROG_OPENCODE_PLUGIN_SOURCE = `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
120057
+ // surfaces opencode subagent activity that the CLI's run-loop discards. see
120058
+ // action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
120059
+ // inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
120060
+ // the user's working tree. the subagent gate ships as a separate plugin
120061
+ // (pullfrog-subagent-gate.ts).
120062
+
120063
+ const PULLFROG_BUS_EVENT_TYPE = ${JSON.stringify(PULLFROG_BUS_EVENT_TYPE)};
120064
+
120065
+ // orchestratorSessionID locks to the first sessionID we observe on the bus
120066
+ // (\`message.part.updated\`). opencode's run command creates exactly one
120067
+ // top-level session before any subagent is dispatched, and the orchestrator
120068
+ // emits at least one event before any subagent does \u2014 so first-seen-wins
120069
+ // captures the orchestrator; every subagent part has a non-matching
120070
+ // sessionID.
120071
+ let orchestratorSessionID: string | undefined;
120072
+
120073
+ function isOrchestratorTaskDispatch(part: {
120074
+ type?: string;
120075
+ tool?: string;
120076
+ state?: { status?: string };
120077
+ } | undefined): boolean {
120078
+ if (!part || part.type !== "tool") return false;
120079
+ if (part.tool !== "task") return false;
120080
+ // only forward at status="running" (not "pending"). at pending the
120081
+ // state.input is still {} \u2014 the orchestrator has emitted the part shell
120082
+ // but the LLM hasn't filled in description/subagent_type/prompt yet. by
120083
+ // running, input is populated and recordTaskDispatch can derive the lens
120084
+ // label correctly.
120085
+ return part.state?.status === "running";
120086
+ }
120087
+
120088
+ export default async function pullfrogEventsPlugin() {
120089
+ return {
120090
+ event: async (input: {
120091
+ event: {
120092
+ type: string;
120093
+ properties?: {
120094
+ part?: {
120095
+ sessionID?: string;
120096
+ type?: string;
120097
+ tool?: string;
120098
+ state?: { status?: string };
120099
+ };
120100
+ };
120101
+ };
120102
+ }) => {
120103
+ const event = input?.event;
120104
+ if (!event || typeof event !== "object") return;
120105
+ if (event.type !== "message.part.updated") return;
120106
+ const part = event.properties?.part;
120107
+ const sessionID = part?.sessionID;
120108
+ if (typeof sessionID !== "string" || sessionID.length === 0) return;
120109
+ if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
120110
+
120111
+ if (sessionID === orchestratorSessionID) {
120112
+ // skip orchestrator events EXCEPT early task dispatches.
120113
+ if (!isOrchestratorTaskDispatch(part)) return;
120114
+ }
120115
+
120116
+ try {
120117
+ const line = JSON.stringify({
120118
+ type: PULLFROG_BUS_EVENT_TYPE,
120119
+ bus_event: event,
120120
+ });
120121
+ process.stdout.write(line + "\\n");
120122
+ } catch {
120123
+ // a circular reference or BigInt etc. would throw; swallow rather
120124
+ // than letting a single bad event take down the plugin.
120125
+ }
120126
+ },
120127
+ };
120128
+ }
120129
+ `;
120130
+ function buildOpencodeSubagentGateSource(ctx) {
120131
+ const bashRefusal = ctx.payload.shell === "disabled" ? "shell execution is disabled for this run and no shell tool exists \u2014 use read, grep and glob instead." : `native bash is disabled in Pullfrog runs \u2014 call ${formatMcpToolRef("opencode", "shell")} with the same command instead.`;
120132
+ return `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
120133
+ // hard-blocks state-mutating MCP tool calls from opencode subagents (PR
120134
+ // following pullfrog/app#796 \u2014 see action/agents/subagentToolGates.ts). see
120135
+ // action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
120136
+ // inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
120137
+ // the user's working tree.
120138
+
120139
+ // embedded at template-render time so the plugin source has no imports. the
120140
+ // denied set is derived from each tool's \`mutates\` flag \u2014 see
120141
+ // action/agents/subagentToolGates.ts.
120142
+ const SUBAGENT_DENIED_TOOLS = new Set(${JSON.stringify(ctx.subagentDeniedTools)});
120143
+
120144
+ // opencode presents MCP tools as \`pullfrog_<bare>\`; strip that to compare
120145
+ // against the canonical bare names in SUBAGENT_DENIED_TOOLS. native tools
120146
+ // (read, write, bash, task, etc.) lack the prefix and never match the deny
120147
+ // list anyway.
120148
+ function stripPullfrogPrefix(toolName: string): string {
120149
+ if (toolName.startsWith("pullfrog_")) return toolName.slice("pullfrog_".length);
120150
+ return toolName;
120151
+ }
120152
+
120153
+ // orchestratorSessionID locks to the first sessionID we observe on
120154
+ // \`tool.execute.before\`. the orchestrator must invoke a tool (e.g. its
120155
+ // \`task\` dispatch) before any subagent session exists, so first-seen-wins
120156
+ // always captures the orchestrator; every subagent call therefore has a
120157
+ // non-matching sessionID and is subject to the deny check.
120158
+ let orchestratorSessionID: string | undefined;
120159
+
120160
+ export default async function pullfrogSubagentGatePlugin() {
120161
+ return {
120162
+ "tool.execute.before": async (
120163
+ input: { tool?: string; sessionID?: string; callID?: string },
120164
+ _output: { args?: Record<string, unknown> }
120165
+ ) => {
120166
+ // input: { tool, sessionID, callID } \u2014 confirmed against
120167
+ // anomalyco/opencode packages/opencode/src/session/tools.ts which
120168
+ // calls plugin.trigger("tool.execute.before", { tool, sessionID,
120169
+ // callID }, { args }). throwing here surfaces as a tool error to the
120170
+ // model (Effect's run.promise propagates the rejection through the
120171
+ // AI SDK's tool-runtime), so the subagent sees a denial and can
120172
+ // pick a different action.
120173
+ const sessionID = typeof input?.sessionID === "string" ? input.sessionID : "";
120174
+ const tool = typeof input?.tool === "string" ? input.tool : "";
120175
+ // above the sessionID check: the bash block must never depend on a
120176
+ // session id being present.
120177
+ if (tool === "bash") {
120178
+ throw new Error(${JSON.stringify(bashRefusal)});
120179
+ }
120180
+ if (!sessionID || !tool) return;
120181
+ if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
120182
+ if (sessionID === orchestratorSessionID) return;
120183
+ const bare = stripPullfrogPrefix(tool);
120184
+ if (!SUBAGENT_DENIED_TOOLS.has(bare)) return;
120185
+ throw new Error(
120186
+ "subagent attempted to call denied tool '" + bare + "'. " +
120187
+ "subagents share the orchestrator's in-process working tree and toolState; " +
120188
+ "state-changing MCP tools (checkout_pr, push_branch, create_pull_request_review, " +
120189
+ "report_progress, etc.) are reserved for the orchestrator. " +
120190
+ "report findings back to the orchestrator and let it perform the mutation."
120191
+ );
120192
+ },
120193
+ };
120194
+ }
120195
+ `;
120196
+ }
120197
+
120123
120198
  // agents/subagentModels.ts
120124
120199
  function deriveSubagentModels(orchestratorSpec) {
120125
120200
  if (!orchestratorSpec) return { reviewer: void 0 };
@@ -120494,6 +120569,7 @@ async function dispatchEvent(ctx, event) {
120494
120569
  const err = event.properties.error;
120495
120570
  const message = err ? extractErrorMessage(err) : "(no error payload)";
120496
120571
  log2.info(`\xBB ${ctx.label} session error: ${message}`);
120572
+ if (err) ctx.lastSessionError = message;
120497
120573
  return;
120498
120574
  }
120499
120575
  }
@@ -120632,6 +120708,7 @@ async function runPromptTurn(ctx, params) {
120632
120708
  ctx.promptMessageID = void 0;
120633
120709
  ctx.diagnostic.sawModelOutput = false;
120634
120710
  ctx.diagnostic.idleSec = void 0;
120711
+ ctx.lastSessionError = void 0;
120635
120712
  let assistant;
120636
120713
  let returnedParts;
120637
120714
  let networkError = null;
@@ -120676,11 +120753,12 @@ async function runPromptTurn(ctx, params) {
120676
120753
  }
120677
120754
  const cause = ctx.serverExitCause() ?? ctx.diagnostic.lastProviderError;
120678
120755
  const diagnosis = cause ? ` \u2014 likely cause: ${cause}` : "";
120756
+ const promptCause = ctx.serverExitCause() ?? (ctx.diagnostic.sawModelOutput ? void 0 : ctx.lastSessionError) ?? ctx.diagnostic.lastProviderError;
120679
120757
  if (networkError) {
120680
120758
  return {
120681
120759
  success: false,
120682
120760
  output: finalText,
120683
- error: params.signal.aborted ? `activity timeout: the model went silent and the turn was aborted by the activity watchdog (${networkError})${diagnosis}` : `opencode prompt failed: ${networkError}${diagnosis}`,
120761
+ error: params.signal.aborted ? `activity timeout: the model went silent and the turn was aborted by the activity watchdog (${networkError})${diagnosis}` : `opencode prompt failed: ${networkError}${promptCause ? ` \u2014 likely cause: ${promptCause}` : ""}`,
120684
120762
  usage
120685
120763
  };
120686
120764
  }
@@ -120872,15 +120950,14 @@ var opencode = agent({
120872
120950
  const isBedrockRoute = rawModel !== void 0 && bedrockModelId !== void 0 && bedrockModelId === rawModel;
120873
120951
  const vertexModel = resolveVertexOpenCodeModel(rawModel);
120874
120952
  const model = vertexModel ?? (isBedrockRoute ? `amazon-bedrock/${rawModel}` : rawModel);
120875
- const homeEnv = {
120876
- HOME: ctx.tmpdir,
120877
- XDG_CONFIG_HOME: join8(ctx.tmpdir, ".config")
120878
- };
120879
- const opencodePluginDir = join8(homeEnv.XDG_CONFIG_HOME, "opencode", "plugin");
120953
+ const codexAuth = installCodexAuth();
120954
+ const xaiAuth = installXaiAuth();
120955
+ const homeEnv = openCodeHomeEnv(ctx.tmpdir);
120956
+ const opencodePluginDir = join9(homeEnv.XDG_CONFIG_HOME, "opencode", "plugin");
120880
120957
  mkdirSync7(opencodePluginDir, { recursive: true });
120881
120958
  writeFileSync7(
120882
- join8(opencodePluginDir, PULLFROG_OPENCODE_GATE_PLUGIN_FILENAME),
120883
- buildOpencodeSubagentGateSource(ctx.subagentDeniedTools)
120959
+ join9(opencodePluginDir, PULLFROG_OPENCODE_GATE_PLUGIN_FILENAME),
120960
+ buildOpencodeSubagentGateSource(ctx)
120884
120961
  );
120885
120962
  const agentBrowserVersion = getDevDependencyVersion("agent-browser");
120886
120963
  addSkill({
@@ -120890,8 +120967,6 @@ var opencode = agent({
120890
120967
  agent: "opencode"
120891
120968
  });
120892
120969
  installBundledSkills({ home: homeEnv.HOME });
120893
- const codexAuth = installCodexAuth();
120894
- const xaiAuth = installXaiAuth();
120895
120970
  const permissionOverride = JSON.stringify({
120896
120971
  external_directory: { "*": "deny", "/tmp/*": "allow" },
120897
120972
  read: { "*": "allow", ...GIT_NATIVE_READ_DENY_OPENCODE },
@@ -120910,7 +120985,6 @@ var opencode = agent({
120910
120985
  delete env2.OPENCODE_EXPERIMENTAL_CODE_MODE;
120911
120986
  const writebacks = [];
120912
120987
  if (codexAuth) {
120913
- env2.XDG_DATA_HOME = codexAuth.xdgDataHome;
120914
120988
  delete env2.OPENAI_API_KEY;
120915
120989
  writebacks.push({
120916
120990
  secretName: "CODEX_AUTH_JSON",
@@ -120921,7 +120995,6 @@ var opencode = agent({
120921
120995
  });
120922
120996
  }
120923
120997
  if (xaiAuth) {
120924
- env2.XDG_DATA_HOME = xaiAuth.xdgDataHome;
120925
120998
  delete env2.XAI_API_KEY;
120926
120999
  writebacks.push({
120927
121000
  secretName: "GROK_AUTH_JSON",
@@ -120994,6 +121067,7 @@ var opencode = agent({
120994
121067
  mcpToolCalls: 0,
120995
121068
  recentStderr: server.recentStderr,
120996
121069
  serverExitCause: server.exitCause,
121070
+ lastSessionError: void 0,
120997
121071
  diagnostic: {
120998
121072
  label: "Pullfrog",
120999
121073
  recentStderr: server.recentStderr,
@@ -175912,7 +175986,7 @@ function closeBrowserDaemon(toolState) {
175912
175986
  // mcp/checkout.ts
175913
175987
  import { createHash as createHash2 } from "node:crypto";
175914
175988
  import { statSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync11 } from "node:fs";
175915
- import { join as join16 } from "node:path";
175989
+ import { join as join17 } from "node:path";
175916
175990
 
175917
175991
  // node_modules/.pnpm/@ark+schema@0.56.0/node_modules/@ark/schema/out/shared/errors.js
175918
175992
  var ArkError = class _ArkError extends CastableBase {
@@ -184283,13 +184357,14 @@ function ensureRepoState(toolState, init) {
184283
184357
  // utils/changeImpact.ts
184284
184358
  import { spawn as spawn4 } from "node:child_process";
184285
184359
  import { writeFileSync as writeFileSync8 } from "node:fs";
184286
- import { join as join9 } from "node:path";
184360
+ import { join as join10 } from "node:path";
184287
184361
  var MAX_CANDIDATES = 24;
184288
184362
  var MAX_ATOM_LENGTH = 128;
184289
184363
  var MAX_ATOMS = 12;
184290
184364
  var MAX_REFERENCES = 6;
184291
184365
  var MAX_EXCERPT = 160;
184292
184366
  var MAX_BYTES = 16e3;
184367
+ var LOOKUP_BUDGET_MS = 6e4;
184293
184368
  function isSymbolShaped(atom) {
184294
184369
  return atom.length >= 4 && atom.length <= MAX_ATOM_LENGTH && /[A-Z_]/.test(atom);
184295
184370
  }
@@ -184492,7 +184567,7 @@ function consumeGrepChunk(params) {
184492
184567
  async function lookupReferences(params) {
184493
184568
  const buckets = emptyBuckets(params.candidates);
184494
184569
  if (params.candidates.length === 0) return { buckets, error: void 0 };
184495
- const patterns = params.candidates.flatMap((candidate) => ["-e", candidate.atom]);
184570
+ const pattern = params.candidates.map((candidate) => candidate.atom).join("|");
184496
184571
  const child = spawn4(
184497
184572
  "git",
184498
184573
  [
@@ -184503,13 +184578,14 @@ async function lookupReferences(params) {
184503
184578
  "-z",
184504
184579
  "-I",
184505
184580
  "-w",
184506
- "-F",
184507
- ...patterns,
184581
+ "-E",
184582
+ "-e",
184583
+ pattern,
184508
184584
  params.treeish,
184509
184585
  "--",
184510
184586
  ":(top)"
184511
184587
  ],
184512
- { env: resolveEnv(void 0), stdio: ["ignore", "pipe", "pipe"] }
184588
+ { env: resolveEnv(void 0), stdio: ["ignore", "pipe", "pipe"], signal: params.signal }
184513
184589
  );
184514
184590
  const state = createGrepRecordState();
184515
184591
  const maxAtomLength = Math.max(...params.candidates.map((candidate) => candidate.atom.length));
@@ -184529,7 +184605,7 @@ async function lookupReferences(params) {
184529
184605
  child.stderr.resume();
184530
184606
  const status = await new Promise((resolve3) => {
184531
184607
  child.once("error", (error49) => {
184532
- spawnError = error49.message;
184608
+ spawnError = error49.cause instanceof Error ? error49.cause.message : error49.message;
184533
184609
  resolve3(null);
184534
184610
  });
184535
184611
  child.once("close", resolve3);
@@ -184617,8 +184693,10 @@ async function createChangeImpactArtifact(ctx, params) {
184617
184693
  const lookup2 = await lookupReferences({
184618
184694
  candidates: candidates.selected,
184619
184695
  addedLines: candidates.addedLines,
184620
- treeish: params.headSha
184696
+ treeish: params.headSha,
184697
+ signal: AbortSignal.any([params.signal, AbortSignal.timeout(LOOKUP_BUDGET_MS)])
184621
184698
  });
184699
+ params.signal.throwIfAborted();
184622
184700
  const artifact = formatArtifact({
184623
184701
  entries: candidates.selected.map((change) => {
184624
184702
  return { change, bucket: lookup2.buckets.get(change.atom) ?? { count: 0, samples: [] } };
@@ -184630,7 +184708,7 @@ async function createChangeImpactArtifact(ctx, params) {
184630
184708
  headSha: params.headSha,
184631
184709
  lookupError: lookup2.error
184632
184710
  });
184633
- const path4 = join9(ctx.tmpdir, `pr-${params.pullNumber}-${params.headSha.slice(0, 7)}-impact.md`);
184711
+ const path4 = join10(ctx.tmpdir, `pr-${params.pullNumber}-${params.headSha.slice(0, 7)}-impact.md`);
184634
184712
  writeFileSync8(path4, artifact.content);
184635
184713
  return {
184636
184714
  path: path4,
@@ -184884,7 +184962,7 @@ function readNumber(params) {
184884
184962
  import { execSync } from "node:child_process";
184885
184963
  import { createHash } from "node:crypto";
184886
184964
  import { readFileSync as readFileSync6, realpathSync, unlinkSync } from "node:fs";
184887
- import { join as join10 } from "node:path";
184965
+ import { join as join11 } from "node:path";
184888
184966
 
184889
184967
  // utils/shell.ts
184890
184968
  import { spawnSync as spawnSync6 } from "node:child_process";
@@ -184974,7 +185052,7 @@ function resolveHooksDir(cwd, gitPath) {
184974
185052
  cwd,
184975
185053
  log: false
184976
185054
  }).trim();
184977
- const hooksDir = join10(commonDir, "hooks");
185055
+ const hooksDir = join11(commonDir, "hooks");
184978
185056
  hooksDirCache.set(cwd, hooksDir);
184979
185057
  return hooksDir;
184980
185058
  }
@@ -185021,7 +185099,8 @@ async function $git(subcommand, args2, options) {
185021
185099
  GIT_CONFIG_COUNT: "0",
185022
185100
  GIT_CONFIG_PARAMETERS: ""
185023
185101
  },
185024
- activityTimeout: 0
185102
+ activityTimeout: 0,
185103
+ signal: options.signal
185025
185104
  });
185026
185105
  if (result.stderr.includes("askpass-compromised")) {
185027
185106
  log2.info("askpass code was replayed after revoke \u2014 token has been revoked");
@@ -185088,9 +185167,10 @@ import { spawnSync as spawnSync8 } from "node:child_process";
185088
185167
  // mcp/shell.ts
185089
185168
  import { spawn as spawn5, spawnSync as spawnSync7 } from "node:child_process";
185090
185169
  import { randomUUID as randomUUID3 } from "node:crypto";
185091
- import { closeSync, openSync, writeFileSync as writeFileSync9 } from "node:fs";
185170
+ import { once } from "node:events";
185171
+ import { closeSync, existsSync as existsSync9, openSync, writeFileSync as writeFileSync9 } from "node:fs";
185092
185172
  import { userInfo as userInfo2 } from "node:os";
185093
- import { join as join11 } from "node:path";
185173
+ import { join as join12 } from "node:path";
185094
185174
  import { setTimeout as sleep3 } from "node:timers/promises";
185095
185175
 
185096
185176
  // node_modules/.pnpm/@toon-format+toon@1.4.0/node_modules/@toon-format/toon/dist/index.mjs
@@ -185734,7 +185814,7 @@ function buildFsMounts(repoDir) {
185734
185814
  function shimMount() {
185735
185815
  const tempDir = process.env.PULLFROG_TEMP_DIR;
185736
185816
  if (!tempDir) return "";
185737
- const dir = join11(tempDir, "pm-bin").replace(/'/g, "'\\''");
185817
+ const dir = join12(tempDir, "pm-bin").replace(/'/g, "'\\''");
185738
185818
  return `[ -d '${dir}' ] && mount --bind '${dir}' '${dir}' 2>/dev/null && mount -o remount,bind,ro '${dir}' 2>/dev/null;`;
185739
185819
  }
185740
185820
  var _repoRoot;
@@ -185905,7 +185985,7 @@ ${spawnError}` : spawnError;
185905
185985
  }
185906
185986
  function capOutput(output) {
185907
185987
  if (output.length <= MAX_OUTPUT_CHARS) return output;
185908
- const fullPath = join11(getTempDir(), `shell-${randomUUID3().slice(0, 8)}.log`);
185988
+ const fullPath = join12(getTempDir(), `shell-${randomUUID3().slice(0, 8)}.log`);
185909
185989
  writeFileSync9(fullPath, output);
185910
185990
  const elided = output.length - MAX_OUTPUT_CHARS;
185911
185991
  return `... [${elided} chars truncated; output saved to ${fullPath}] ...
@@ -185942,6 +186022,7 @@ Do NOT use this tool for git commands \u2014 use the dedicated git tools instead
185942
186022
  }
185943
186023
  const timeout = Math.min(params.timeout ?? 3e4, 12e4);
185944
186024
  const cwd = params.working_directory ?? process.cwd();
186025
+ if (!existsSync9(cwd)) throw new Error(`working_directory does not exist: ${cwd}`);
185945
186026
  const env2 = resolveEnv(ctx.payload.shell === "enabled" ? "inherit" : "restricted");
185946
186027
  if (params.command.includes("agent-browser")) {
185947
186028
  const daemonError = ensureBrowserDaemon(ctx.toolState);
@@ -185960,8 +186041,8 @@ Do NOT use this tool for git commands \u2014 use the dedicated git tools instead
185960
186041
  if (params.background) {
185961
186042
  const tempDir = getTempDir();
185962
186043
  const handle = `bg-${randomUUID3().slice(0, 8)}`;
185963
- const outputPath = join11(tempDir, `${handle}.log`);
185964
- const pidPath = join11(tempDir, `${handle}.pid`);
186044
+ const outputPath = join12(tempDir, `${handle}.log`);
186045
+ const pidPath = join12(tempDir, `${handle}.pid`);
185965
186046
  const logFd = openSync(outputPath, "a");
185966
186047
  let proc;
185967
186048
  try {
@@ -185975,7 +186056,8 @@ Do NOT use this tool for git commands \u2014 use the dedicated git tools instead
185975
186056
  closeSync(logFd);
185976
186057
  }
185977
186058
  if (!proc.pid) {
185978
- throw new Error("failed to start background process");
186059
+ const [error49] = await once(proc, "error");
186060
+ throw new Error(`failed to start background process: ${error49.message}`);
185979
186061
  }
185980
186062
  proc.unref();
185981
186063
  writeFileSync9(pidPath, `${proc.pid}
@@ -186252,12 +186334,12 @@ function postProcessRangeDiff(raw2, contextLines = 3) {
186252
186334
  // mcp/git.ts
186253
186335
  import { randomUUID as randomUUID4 } from "node:crypto";
186254
186336
  import { writeFileSync as writeFileSync10 } from "node:fs";
186255
- import { join as join14 } from "node:path";
186337
+ import { join as join15 } from "node:path";
186256
186338
 
186257
186339
  // utils/apiCommit.ts
186258
186340
  import { execFileSync as execFileSync6 } from "node:child_process";
186259
186341
  import { lstat, readlink } from "node:fs/promises";
186260
- import { join as join12 } from "node:path";
186342
+ import { join as join13 } from "node:path";
186261
186343
  var GITHUB_API = "https://api.github.com";
186262
186344
  var MAX_BLOB_BYTES = 30 * 1024 * 1024;
186263
186345
  var BLOB_UPLOAD_CONCURRENCY = 8;
@@ -186322,7 +186404,7 @@ async function assertApiCommittable(files) {
186322
186404
  const root = getRepoRoot();
186323
186405
  const attrs = $(
186324
186406
  "git",
186325
- ["check-attr", "filter", "-z", "--", ...present.map((p) => join12(root, p))],
186407
+ ["check-attr", "filter", "-z", "--", ...present.map((p) => join13(root, p))],
186326
186408
  { log: false }
186327
186409
  );
186328
186410
  const parts = attrs.split("\0");
@@ -186334,7 +186416,7 @@ async function assertApiCommittable(files) {
186334
186416
  }
186335
186417
  }
186336
186418
  for (const path4 of present) {
186337
- const stat = await lstat(join12(root, path4));
186419
+ const stat = await lstat(join13(root, path4));
186338
186420
  if (stat.isDirectory()) {
186339
186421
  throw new Error(
186340
186422
  `'${path4}' is a directory (nested repository or submodule?) \u2014 signed commits only support files and symlinks.`
@@ -186343,7 +186425,7 @@ async function assertApiCommittable(files) {
186343
186425
  }
186344
186426
  }
186345
186427
  async function createBlobEntry(params) {
186346
- const absPath = join12(params.repoRoot, params.path);
186428
+ const absPath = join13(params.repoRoot, params.path);
186347
186429
  const stat = await lstat(absPath);
186348
186430
  if (stat.size > MAX_BLOB_BYTES) {
186349
186431
  throw new Error(
@@ -186523,7 +186605,7 @@ this usually means your local branch has commits that were never pushed \u2014 s
186523
186605
  var core4 = __toESM(require_core(), 1);
186524
186606
  import { createSign } from "node:crypto";
186525
186607
  import { rename, writeFile } from "node:fs/promises";
186526
- import { dirname as dirname3, join as join13 } from "node:path";
186608
+ import { dirname as dirname3, join as join14 } from "node:path";
186527
186609
 
186528
186610
  // node_modules/.pnpm/@octokit+plugin-throttling@11.0.5_@octokit+core@7.0.8/node_modules/@octokit/plugin-throttling/dist-bundle/index.js
186529
186611
  var import_light = __toESM(require_light(), 1);
@@ -191072,7 +191154,7 @@ function getGitHubUsageSummary() {
191072
191154
  }
191073
191155
  async function writeGitHubUsageSummaryToFile(path4) {
191074
191156
  const summary2 = getGitHubUsageSummary();
191075
- const tmpPath = join13(dirname3(path4), `.usage-summary-${process.pid}.tmp`);
191157
+ const tmpPath = join14(dirname3(path4), `.usage-summary-${process.pid}.tmp`);
191076
191158
  await writeFile(tmpPath, JSON.stringify(summary2));
191077
191159
  await rename(tmpPath, path4);
191078
191160
  }
@@ -191700,7 +191782,7 @@ function countAhead(head, base, cwd) {
191700
191782
  function spillGitOutput(params) {
191701
191783
  const tempDir = process.env.PULLFROG_TEMP_DIR;
191702
191784
  if (!tempDir) throw new Error("PULLFROG_TEMP_DIR not set");
191703
- const outputPath = join14(tempDir, `git-${params.command}-${randomUUID4().slice(0, 8)}.txt`);
191785
+ const outputPath = join15(tempDir, `git-${params.command}-${randomUUID4().slice(0, 8)}.txt`);
191704
191786
  writeFileSync10(outputPath, params.output);
191705
191787
  const previewByLines = params.output.split("\n").slice(0, OVERFLOW_PREVIEW_LINES).join("\n");
191706
191788
  const preview = previewByLines.length <= OVERFLOW_PREVIEW_MAX_CHARS ? previewByLines : `${previewByLines.slice(0, OVERFLOW_PREVIEW_MAX_CHARS)}\u2026`;
@@ -193355,11 +193437,11 @@ async function reportReviewNodeId(ctx, params) {
193355
193437
 
193356
193438
  // utils/setup.ts
193357
193439
  import { execFileSync as execFileSync7, execSync as execSync2 } from "node:child_process";
193358
- import { mkdtempSync as mkdtempSync2, readdirSync, realpathSync as realpathSync2, rmSync as rmSync3, unlinkSync as unlinkSync2 } from "node:fs";
193440
+ import { mkdtempSync as mkdtempSync3, readdirSync, realpathSync as realpathSync2, rmSync as rmSync4, unlinkSync as unlinkSync2 } from "node:fs";
193359
193441
  import { tmpdir as tmpdir3 } from "node:os";
193360
- import { join as join15 } from "node:path";
193442
+ import { join as join16 } from "node:path";
193361
193443
  function createTempDirectory() {
193362
- const path4 = mkdtempSync2(join15(tmpdir3(), "pullfrog-"));
193444
+ const path4 = mkdtempSync3(join16(tmpdir3(), "pullfrog-"));
193363
193445
  process.env.PULLFROG_TEMP_DIR = path4;
193364
193446
  log2.info(`\xBB created temp dir at ${path4}`);
193365
193447
  return { path: path4, [Symbol.dispose]: () => removeTempDirectory(path4) };
@@ -193367,10 +193449,10 @@ function createTempDirectory() {
193367
193449
  function removeTempDirectory(path4) {
193368
193450
  try {
193369
193451
  try {
193370
- rmSync3(path4, { recursive: true, force: true });
193452
+ rmSync4(path4, { recursive: true, force: true });
193371
193453
  } catch {
193372
193454
  execFileSync7("chmod", ["-R", "u+w", path4]);
193373
- rmSync3(path4, { recursive: true, force: true });
193455
+ rmSync4(path4, { recursive: true, force: true });
193374
193456
  }
193375
193457
  log2.debug(`\xBB removed temp dir at ${path4}`);
193376
193458
  } catch (err) {
@@ -193419,13 +193501,13 @@ function wipeRunnerLeakSurface() {
193419
193501
  return [];
193420
193502
  }
193421
193503
  };
193422
- const fileCommandsDir = join15(runnerTemp, "_runner_file_commands");
193504
+ const fileCommandsDir = join16(runnerTemp, "_runner_file_commands");
193423
193505
  for (const entry of listDir(fileCommandsDir)) {
193424
- tryUnlink(join15(fileCommandsDir, entry));
193506
+ tryUnlink(join16(fileCommandsDir, entry));
193425
193507
  }
193426
193508
  for (const entry of listDir(runnerTemp)) {
193427
193509
  if (entry.endsWith(".sh") || /^git-credentials-.*\.config$/.test(entry)) {
193428
- tryUnlink(join15(runnerTemp, entry));
193510
+ tryUnlink(join16(runnerTemp, entry));
193429
193511
  }
193430
193512
  }
193431
193513
  if (wiped.length > 0) {
@@ -193697,7 +193779,7 @@ async function ensureBeforeShaReachable(params) {
193697
193779
  "origin",
193698
193780
  tempBranch
193699
193781
  ],
193700
- { token: params.gitToken, refreshGitToken: params.refreshGitToken },
193782
+ { token: params.gitToken, refreshGitToken: params.refreshGitToken, signal: params.signal },
193701
193783
  `before_sha temp branch ${tempBranch}`
193702
193784
  );
193703
193785
  log2.debug(`\xBB fetched before_sha via temp branch ${tempBranch}`);
@@ -193738,7 +193820,7 @@ function cleanupStaleGitLocks() {
193738
193820
  } catch {
193739
193821
  continue;
193740
193822
  }
193741
- if (now - mtimeMs < STALE_LOCK_AGE_MS) continue;
193823
+ if (now - mtimeMs < STALE_LOCK_AGE_MS || mtimeMs >= performance.timeOrigin) continue;
193742
193824
  try {
193743
193825
  unlinkSync3(relPath);
193744
193826
  log2.warning(`\xBB removed stale ${relPath} from prior run`);
@@ -193771,7 +193853,7 @@ async function abortIfPullRequestMoved(args2) {
193771
193853
  );
193772
193854
  }
193773
193855
  async function checkoutPrBranch(pr, params) {
193774
- const { octokit, owner, name, gitToken, refreshGitToken, toolState, beforeSha } = params;
193856
+ const { octokit, owner, name, gitToken, refreshGitToken, toolState, beforeSha, signal } = params;
193775
193857
  rejectIfLeadingDash(pr.baseRef, "PR base ref");
193776
193858
  rejectIfLeadingDash(pr.headRef, "PR head ref");
193777
193859
  const repoState = requireRepoState(toolState, owner, name);
@@ -193785,7 +193867,7 @@ async function checkoutPrBranch(pr, params) {
193785
193867
  log2.debug(`\xBB fetching base branch (${pr.baseRef})...`);
193786
193868
  await $gitFetchWithDeepen(
193787
193869
  ["--no-tags", "origin", pr.baseRef],
193788
- { token: gitToken, refreshGitToken },
193870
+ { token: gitToken, refreshGitToken, signal },
193789
193871
  `base branch ${pr.baseRef}`
193790
193872
  );
193791
193873
  if (!alreadyOnBranch) {
@@ -193796,7 +193878,7 @@ async function checkoutPrBranch(pr, params) {
193796
193878
  try {
193797
193879
  await $gitFetchWithDeepen(
193798
193880
  ["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`],
193799
- { token: gitToken, refreshGitToken },
193881
+ { token: gitToken, refreshGitToken, signal },
193800
193882
  `PR #${pr.number}`
193801
193883
  );
193802
193884
  } catch (e) {
@@ -193821,7 +193903,8 @@ async function checkoutPrBranch(pr, params) {
193821
193903
  repo: name,
193822
193904
  gitToken,
193823
193905
  refreshGitToken,
193824
- isShallow
193906
+ isShallow,
193907
+ signal
193825
193908
  }) : false;
193826
193909
  if (isShallow) {
193827
193910
  let deepenDepth = 0;
@@ -193856,7 +193939,8 @@ async function checkoutPrBranch(pr, params) {
193856
193939
  if (deepenDepth) {
193857
193940
  log2.debug(`\xBB deepening by ${deepenDepth} to reach merge base...`);
193858
193941
  await $git("fetch", [`--deepen=${deepenDepth}`, "--no-tags", "origin"], {
193859
- token: gitToken
193942
+ token: gitToken,
193943
+ signal
193860
193944
  });
193861
193945
  }
193862
193946
  }
@@ -193894,6 +193978,7 @@ async function checkoutPrBranch(pr, params) {
193894
193978
  remoteBranch: pr.headRef,
193895
193979
  localBranch
193896
193980
  };
193981
+ signal.throwIfAborted();
193897
193982
  const postCheckoutHook = await executeLifecycleHook({
193898
193983
  event: "post-checkout",
193899
193984
  script: params.postCheckoutScript,
@@ -193904,19 +193989,30 @@ async function checkoutPrBranch(pr, params) {
193904
193989
  }
193905
193990
  var inFlightCheckouts = /* @__PURE__ */ new Map();
193906
193991
  var CHECKOUT_DEADLINE_MS = 57e4;
193907
- function withCheckoutDeadline(promise2, pullNumber) {
193908
- let timer;
193909
- const deadline = new Promise((_, reject) => {
193910
- timer = setTimeout(
193992
+ var CHECKOUT_ABORT_GRACE_MS = 15e3;
193993
+ function withCheckoutDeadline(run4, pullNumber) {
193994
+ const exceeded = `checkout_pr #${pullNumber} exceeded ${CHECKOUT_DEADLINE_MS / 1e3}s`;
193995
+ const retry = "retry the call to start a fresh attempt.";
193996
+ const controller = new AbortController();
193997
+ const abortTimer = setTimeout(
193998
+ () => controller.abort(new Error(`${exceeded} and was cancelled; ${retry}`)),
193999
+ CHECKOUT_DEADLINE_MS
194000
+ );
194001
+ let backstopTimer;
194002
+ const backstop = new Promise((_, reject) => {
194003
+ backstopTimer = setTimeout(
193911
194004
  () => reject(
193912
194005
  new Error(
193913
- `checkout_pr #${pullNumber} exceeded ${CHECKOUT_DEADLINE_MS / 1e3}s. the fetch may still be running in the background; retry the call to start a fresh attempt.`
194006
+ `${exceeded} and was asked to cancel, but a step that cannot be interrupted (the post-checkout hook or a GitHub API call) was still running ${CHECKOUT_ABORT_GRACE_MS / 1e3}s later. ${retry}`
193914
194007
  )
193915
194008
  ),
193916
- CHECKOUT_DEADLINE_MS
194009
+ CHECKOUT_DEADLINE_MS + CHECKOUT_ABORT_GRACE_MS
193917
194010
  );
193918
194011
  });
193919
- return Promise.race([promise2, deadline]).finally(() => clearTimeout(timer));
194012
+ return Promise.race([run4(controller.signal), backstop]).finally(() => {
194013
+ clearTimeout(abortTimer);
194014
+ clearTimeout(backstopTimer);
194015
+ });
193920
194016
  }
193921
194017
  function headsEqual(a2, b) {
193922
194018
  if (a2.kind === "branch" && b.kind === "branch") return a2.name === b.name;
@@ -193928,7 +194024,7 @@ function describeHead(h2) {
193928
194024
  return `detached HEAD \`${h2.sha}\``;
193929
194025
  }
193930
194026
  function CheckoutPrTool(ctx) {
193931
- const runCheckout = async (pull_number) => {
194027
+ const runCheckout = async (pull_number, signal) => {
193932
194028
  const prResponse = await ctx.octokit.rest.pulls.get({
193933
194029
  owner: ctx.repo.owner,
193934
194030
  repo: ctx.repo.name,
@@ -193957,7 +194053,8 @@ function CheckoutPrTool(ctx) {
193957
194053
  toolState: ctx.toolState,
193958
194054
  shell: ctx.payload.shell,
193959
194055
  postCheckoutScript: ctx.postCheckoutScript,
193960
- beforeSha: primary.beforeSha
194056
+ beforeSha: primary.beforeSha,
194057
+ signal
193961
194058
  });
193962
194059
  const tempDir = process.env.PULLFROG_TEMP_DIR;
193963
194060
  if (!tempDir) {
@@ -193979,7 +194076,7 @@ function CheckoutPrTool(ctx) {
193979
194076
  headSha: checkoutSha
193980
194077
  });
193981
194078
  if (incremental.status === "ok") {
193982
- incrementalDiffPath = join16(
194079
+ incrementalDiffPath = join17(
193983
194080
  tempDir,
193984
194081
  `pr-${pull_number}-${beforeShort}-${headShort}-incremental.diff`
193985
194082
  );
@@ -193996,7 +194093,7 @@ function CheckoutPrTool(ctx) {
193996
194093
  const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
193997
194094
  log2.debug(`formatted diff preview (first 100 lines):
193998
194095
  ${diffPreview}`);
193999
- const diffPath = join16(tempDir, `pr-${pull_number}-${headShort}.diff`);
194096
+ const diffPath = join17(tempDir, `pr-${pull_number}-${headShort}.diff`);
194000
194097
  writeFileSync11(diffPath, formatResult.content);
194001
194098
  log2.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
194002
194099
  primary.diffCoverage = createDiffCoverageState({
@@ -194036,7 +194133,8 @@ ${diffPreview}`);
194036
194133
  files: formatResult.files,
194037
194134
  pullNumber: pull_number,
194038
194135
  baseSha: prResponse.data.base.sha,
194039
- headSha: checkoutSha
194136
+ headSha: checkoutSha,
194137
+ signal
194040
194138
  });
194041
194139
  impactPath = impact.path;
194042
194140
  log2.info(
@@ -194142,7 +194240,10 @@ ${dirty}`
194142
194240
  );
194143
194241
  }
194144
194242
  }
194145
- const promise2 = withCheckoutDeadline(runCheckout(pull_number), pull_number);
194243
+ const promise2 = withCheckoutDeadline(
194244
+ (signal) => runCheckout(pull_number, signal),
194245
+ pull_number
194246
+ );
194146
194247
  inFlightCheckouts.set(pull_number, promise2);
194147
194248
  try {
194148
194249
  return await promise2;
@@ -194155,7 +194256,7 @@ ${dirty}`
194155
194256
 
194156
194257
  // mcp/checkSuite.ts
194157
194258
  import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync12 } from "node:fs";
194158
- import { join as join17 } from "node:path";
194259
+ import { join as join18 } from "node:path";
194159
194260
  var GetCheckSuiteLogs = type({
194160
194261
  check_suite_id: type.number.describe("the id from check_suite.id")
194161
194262
  });
@@ -194251,7 +194352,7 @@ function GetCheckSuiteLogsTool(ctx) {
194251
194352
  if (!tempDir) {
194252
194353
  throw new Error("PULLFROG_TEMP_DIR not set");
194253
194354
  }
194254
- const logsDir = join17(tempDir, "ci-logs");
194355
+ const logsDir = join18(tempDir, "ci-logs");
194255
194356
  mkdirSync8(logsDir, { recursive: true });
194256
194357
  const jobResults = [];
194257
194358
  for (const run4 of failedRuns) {
@@ -194278,7 +194379,7 @@ function GetCheckSuiteLogsTool(ctx) {
194278
194379
  );
194279
194380
  }
194280
194381
  const logsText = await logsResult.text();
194281
- const logPath = join17(logsDir, `job-${job.id}.log`);
194382
+ const logPath = join18(logsDir, `job-${job.id}.log`);
194282
194383
  writeFileSync12(logPath, logsText);
194283
194384
  const analysis = analyzeLog(logsText, 80);
194284
194385
  const failedSteps = job.steps?.filter((s) => s.conclusion === "failure").map((s) => `Step ${s.number}: ${s.name}`) ?? [];
@@ -194328,7 +194429,7 @@ function GetCheckSuiteLogsTool(ctx) {
194328
194429
 
194329
194430
  // mcp/commitInfo.ts
194330
194431
  import { writeFileSync as writeFileSync13 } from "node:fs";
194331
- import { join as join18 } from "node:path";
194432
+ import { join as join19 } from "node:path";
194332
194433
  var CommitInfo = type({
194333
194434
  sha: type.string.describe("the commit SHA (full or abbreviated) to fetch")
194334
194435
  });
@@ -194352,7 +194453,7 @@ function CommitInfoTool(ctx) {
194352
194453
  "PULLFROG_TEMP_DIR not set - get_commit_info must run in pullfrog action context"
194353
194454
  );
194354
194455
  }
194355
- const diffFile = join18(tempDir, `commit-${sha.slice(0, 7)}.diff`);
194456
+ const diffFile = join19(tempDir, `commit-${sha.slice(0, 7)}.diff`);
194356
194457
  writeFileSync13(diffFile, formatResult.content);
194357
194458
  log2.debug(`wrote commit diff to ${diffFile} (${formatResult.content.length} bytes)`);
194358
194459
  return {
@@ -194489,14 +194590,14 @@ function ReopenCurrentTool(ctx) {
194489
194590
  import { performance as performance8 } from "node:perf_hooks";
194490
194591
 
194491
194592
  // prep/installNodeDependencies.ts
194492
- import { existsSync as existsSync10 } from "node:fs";
194493
- import { join as join20 } from "node:path";
194593
+ import { existsSync as existsSync11 } from "node:fs";
194594
+ import { join as join21 } from "node:path";
194494
194595
 
194495
194596
  // utils/packageManager.ts
194496
194597
  var import_semver2 = __toESM(require_semver2(), 1);
194497
- import { existsSync as existsSync9 } from "node:fs";
194598
+ import { existsSync as existsSync10 } from "node:fs";
194498
194599
  import { mkdir, readFile as readFile3 } from "node:fs/promises";
194499
- import { delimiter as delimiter2, join as join19 } from "node:path";
194600
+ import { delimiter as delimiter2, join as join20 } from "node:path";
194500
194601
  var SUPPORTED_NAMES = ["npm", "pnpm", "yarn", "bun"];
194501
194602
  var COREPACK_MANAGED = ["pnpm", "yarn"];
194502
194603
  function isSupported(name) {
@@ -194534,8 +194635,8 @@ function parseDevEnginesField(field) {
194534
194635
  };
194535
194636
  }
194536
194637
  async function resolvePackageManagerSpec(cwd) {
194537
- const pkgPath = join19(cwd, "package.json");
194538
- if (!existsSync9(pkgPath)) return null;
194638
+ const pkgPath = join20(cwd, "package.json");
194639
+ if (!existsSync10(pkgPath)) return null;
194539
194640
  let pkg;
194540
194641
  try {
194541
194642
  pkg = JSON.parse(await readFile3(pkgPath, "utf-8"));
@@ -194593,7 +194694,7 @@ async function currentVersion(name) {
194593
194694
  return result.stdout.trim();
194594
194695
  }
194595
194696
  function packageManagerBinDir(tmpdir4) {
194596
- return join19(tmpdir4, "pm-bin");
194697
+ return join20(tmpdir4, "pm-bin");
194597
194698
  }
194598
194699
  async function ensurePackageManager(params) {
194599
194700
  const spec = params.spec;
@@ -194656,7 +194757,7 @@ async function installFallback(name, installSpec) {
194656
194757
  });
194657
194758
  if (result.exitCode !== 0) return result.stderr || `failed to install ${name}`;
194658
194759
  if (name === "deno") {
194659
- const denoPath = join19(process.env.HOME || "", ".deno", "bin");
194760
+ const denoPath = join20(process.env.HOME || "", ".deno", "bin");
194660
194761
  process.env.PATH = `${denoPath}:${process.env.PATH}`;
194661
194762
  }
194662
194763
  log2.info(`\xBB installed ${name}`);
@@ -194689,8 +194790,8 @@ async function isCommandAvailable2(command) {
194689
194790
  var installNodeDependencies = {
194690
194791
  name: "installNodeDependencies",
194691
194792
  shouldRun: () => {
194692
- const packageJsonPath = join20(process.cwd(), "package.json");
194693
- return existsSync10(packageJsonPath);
194793
+ const packageJsonPath = join21(process.cwd(), "package.json");
194794
+ return existsSync11(packageJsonPath);
194694
194795
  },
194695
194796
  run: async (options) => {
194696
194797
  const declared = await resolvePackageManagerSpec(process.cwd());
@@ -194784,14 +194885,14 @@ ${errorMessage}`]
194784
194885
  };
194785
194886
 
194786
194887
  // prep/installPythonDependencies.ts
194787
- import { existsSync as existsSync11, readFileSync as readFileSync7 } from "node:fs";
194788
- import { join as join21 } from "node:path";
194888
+ import { existsSync as existsSync12, readFileSync as readFileSync7 } from "node:fs";
194889
+ import { join as join22 } from "node:path";
194789
194890
  function declaresBuildSystem(path4) {
194790
194891
  return /^\s*\[\s*build-system\s*\]/m.test(readFileSync7(path4, "utf8"));
194791
194892
  }
194792
194893
  function configApplies(config3, cwd) {
194793
- const path4 = join21(cwd, config3.file);
194794
- return existsSync11(path4) && (config3.applies?.(path4) ?? true);
194894
+ const path4 = join22(cwd, config3.file);
194895
+ return existsSync12(path4) && (config3.applies?.(path4) ?? true);
194795
194896
  }
194796
194897
  var PYTHON_CONFIGS = [
194797
194898
  {
@@ -195113,7 +195214,7 @@ function AwaitDependencyInstallationTool(ctx) {
195113
195214
 
195114
195215
  // mcp/gh.ts
195115
195216
  import { mkdirSync as mkdirSync9 } from "node:fs";
195116
- import { join as join22 } from "node:path";
195217
+ import { join as join23 } from "node:path";
195117
195218
  var GH_TIMEOUT_MS = 12e4;
195118
195219
  var GhParams = type({
195119
195220
  args: type.string.array().describe(
@@ -195135,7 +195236,7 @@ function GhTool(ctx) {
195135
195236
  description: `Run the GitHub CLI against ${ctx.repo.owner}/${ctx.repo.name}. Pass argv as an array \u2014 there is no shell, so pipes, redirects and \`&&\` do not work; use gh's own \`--json\`/\`--jq\` flags instead. \`gh api graphql\` is available. Authenticated with ${permissions ? formatPermissions(permissions) : "no permissions"} on this repo only; anything else 403s. Pushing, merging and ref writes are not available \u2014 use the git tools.`,
195136
195237
  parameters: GhParams,
195137
195238
  execute: execute(async (params) => {
195138
- const configDir = join22(ctx.tmpdir, "gh-config");
195239
+ const configDir = join23(ctx.tmpdir, "gh-config");
195139
195240
  mkdirSync9(configDir, { recursive: true });
195140
195241
  const command = ["gh", ...params.args.map(shellQuote)].join(" ");
195141
195242
  const result = await runSandboxed({
@@ -195859,7 +195960,7 @@ function PullRequestInfoTool(ctx) {
195859
195960
 
195860
195961
  // mcp/reviewComments.ts
195861
195962
  import { writeFileSync as writeFileSync14 } from "node:fs";
195862
- import { join as join23 } from "node:path";
195963
+ import { join as join24 } from "node:path";
195863
195964
  var COMMENTS_PER_THREAD = 50;
195864
195965
  var REVIEW_THREADS_QUERY = `
195865
195966
  query ($owner: String!, $name: String!, $prNumber: Int!, $cursor: String) {
@@ -196353,7 +196454,7 @@ function GetReviewCommentsTool(ctx) {
196353
196454
  throw new Error("PULLFROG_TEMP_DIR not set");
196354
196455
  }
196355
196456
  const filename = `review-${params.review_id}-threads.md`;
196356
- const commentsPath = join23(tempDir, filename);
196457
+ const commentsPath = join24(tempDir, filename);
196357
196458
  writeFileSync14(commentsPath, formatted.content);
196358
196459
  log2.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`);
196359
196460
  return {
@@ -196722,8 +196823,8 @@ function UploadFileTool(ctx) {
196722
196823
  }
196723
196824
 
196724
196825
  // mcp/xrepo.ts
196725
- import { mkdirSync as mkdirSync10, rmSync as rmSync4 } from "node:fs";
196726
- import { join as join24 } from "node:path";
196826
+ import { mkdirSync as mkdirSync10, rmSync as rmSync5 } from "node:fs";
196827
+ import { join as join25 } from "node:path";
196727
196828
  function assertValidRepoName(name) {
196728
196829
  if (!/^[A-Za-z0-9._-]+$/.test(name) || name.startsWith("-") || name.includes("..")) {
196729
196830
  throw new Error(
@@ -196803,7 +196904,7 @@ function CheckoutRepoTool(ctx) {
196803
196904
  return { path: existing.dir, access: existing.access, note: "already checked out." };
196804
196905
  }
196805
196906
  const access = accessFor(ctx, repo);
196806
- const dir = join24(ctx.tmpdir, "xrepo", repo);
196907
+ const dir = join25(ctx.tmpdir, "xrepo", repo);
196807
196908
  mkdirSync10(dir, { recursive: true });
196808
196909
  const state = ensureRepoState(ctx.toolState, { owner, name: repo, dir, access });
196809
196910
  const rc = resolveRepoCtx(ctx, repo);
@@ -196834,7 +196935,7 @@ function CheckoutRepoTool(ctx) {
196834
196935
  return { path: dir, access, defaultBranch };
196835
196936
  } catch (err) {
196836
196937
  ctx.toolState.repos.delete(repoKey(owner, repo));
196837
- rmSync4(dir, { recursive: true, force: true });
196938
+ rmSync5(dir, { recursive: true, force: true });
196838
196939
  throw err;
196839
196940
  }
196840
196941
  })
@@ -197169,6 +197270,7 @@ function isByokSetupError(text) {
197169
197270
  var SECRETS_UNAVAILABLE_MARKER = "couldn't load your Pullfrog secrets";
197170
197271
  var ROUTER_UNFUNDED_MARKER = "your Pullfrog Router balance is empty";
197171
197272
  var CREDENTIAL_REJECTED_MARKER = "was rejected by its provider";
197273
+ var CREDENTIAL_POOL_REFUSED_MARKER = "No configured credential can serve this run";
197172
197274
  var NoUsableCredentialError = class extends Error {
197173
197275
  };
197174
197276
  function throwKeyError(params) {
@@ -197435,7 +197537,8 @@ function isOAuthCredentialExpiredError(text) {
197435
197537
  text
197436
197538
  ) || // the provider no longer recognises the token at all (#1086) — same dead
197437
197539
  // credential, phrased as a lookup miss rather than a state transition.
197438
- /Could not find the appropriate key in your authentication token/i.test(text) || /Token refresh failed/i.test(text)
197540
+ /Could not find the appropriate key in your authentication token/i.test(text) || // the reversed word order: `Encountered invalidated oauth token for user` (#1474).
197541
+ /(?:expired|invalidated|revoked) (?:OAuth|authentication) token/i.test(text) || /Token refresh failed/i.test(text)
197439
197542
  );
197440
197543
  }
197441
197544
  function isMalformedKeyError(text) {
@@ -197482,7 +197585,7 @@ function buildRejectedCredentialError(params) {
197482
197585
  ].join("\n");
197483
197586
  }
197484
197587
  const where = params.inPullfrogStore ? `[Update it in Pullfrog \u2192](${settingsUrl})` : `[Update the GitHub Actions secret \u2192](https://github.com/${params.owner}/${params.name}/settings/secrets/actions)`;
197485
- const keysUrl = providerKeysUrl(params.credential);
197588
+ const keysUrl = providerDashboard(params.credential)?.keys;
197486
197589
  const issue3 = keysUrl ? `[Issue a new key \u2192](${keysUrl}) \xB7 ` : "";
197487
197590
  return [
197488
197591
  `**Your \`${params.credential}\` ${CREDENTIAL_REJECTED_MARKER}**${detail}, so the agent never ran.`,
@@ -197492,12 +197595,43 @@ function buildRejectedCredentialError(params) {
197492
197595
  `${issue3}${where} \xB7 [Model settings \u2192](${settingsUrl}) \xB7 [Setup docs \u2192](https://docs.pullfrog.com/keys) \xB7 [Ask in Discord \u2192](https://discord.gg/8y96raFg8e)`
197493
197596
  ].join("\n");
197494
197597
  }
197495
- function providerKeysUrl(credential) {
197598
+ function providerDashboard(credential) {
197496
197599
  for (const [id, provider2] of Object.entries(providers)) {
197497
- if (provider2.envVars.includes(credential)) return PROVIDER_DASHBOARDS[id]?.keys;
197600
+ if (provider2.envVars.includes(credential)) return PROVIDER_DASHBOARDS[id];
197498
197601
  }
197499
197602
  return void 0;
197500
197603
  }
197604
+ function buildCredentialPoolRefusedError(params) {
197605
+ const settingsUrl = `${getApiUrl()}/console/${params.owner}/${params.name}`;
197606
+ return [
197607
+ `**${CREDENTIAL_POOL_REFUSED_MARKER}.** Every credential configured for \`${params.model}\` was rejected or is out of quota, so the agent never ran.`,
197608
+ "",
197609
+ ...params.refused.map(
197610
+ (credential) => `- ${describeRefusal({ ...params, credential, settingsUrl })}`
197611
+ ),
197612
+ "",
197613
+ `[Model settings \u2192](${settingsUrl}) \xB7 [Ask in Discord \u2192](https://discord.gg/8y96raFg8e)`
197614
+ ].join("\n");
197615
+ }
197616
+ function describeRefusal(params) {
197617
+ const credential = params.credential;
197618
+ const verdict = credential.verdict;
197619
+ const workflow = credential.source === "workflow";
197620
+ const head = `\`${credential.name}\` from ${workflow ? "the workflow" : `${credential.source} scope`}: \`${verdict.detail}\` \u2014`;
197621
+ const subscription = SUBSCRIPTION_CREDENTIALS[credential.name];
197622
+ const dashboard = providerDashboard(credential.name);
197623
+ if (verdict.status === "exhausted" && subscription) {
197624
+ const wait = verdict.resetAt ? `it resets at **${verdict.resetAt.toISOString().slice(0, 16).replace("T", " ")} UTC**; re-trigger after that` : "re-trigger after the limit resets";
197625
+ return `${head} ${wait}, or add an API key for this provider, which Pullfrog falls back to automatically.`;
197626
+ }
197627
+ if (verdict.status === "exhausted")
197628
+ return `${head} add credit or raise its quota with the provider.${dashboard ? ` [Top up \u2192](${dashboard.billing})` : ""}`;
197629
+ if (subscription)
197630
+ return `${head} re-authenticate with \`${subscription.command}\`, or switch this repo to a model you hold an API key for. [Re-authenticate \u2192](${subscription.docs})`;
197631
+ const where = workflow ? `[Update the GitHub Actions secret \u2192](https://github.com/${params.owner}/${params.name}/settings/secrets/actions)` : `[Update it in Pullfrog \u2192](${params.settingsUrl})`;
197632
+ const issue3 = dashboard?.keys ? `[Issue a new key \u2192](${dashboard.keys}) \xB7 ` : "";
197633
+ return `${head} issue a new key and update the copy Pullfrog uses. ${issue3}${where}`;
197634
+ }
197501
197635
  function formatApiKeyErrorSummary(params) {
197502
197636
  if (params.raw.includes(MISSING_KEY_MARKER)) {
197503
197637
  if (params.raw.startsWith(`**${MISSING_KEY_MARKER}**`)) return params.raw;
@@ -198227,11 +198361,16 @@ async function selectConfiguredCredential(input) {
198227
198361
  if (option.candidate) log2.info(`\xBB selected ${option.name} from ${from}`);
198228
198362
  return true;
198229
198363
  }
198230
- refused.push(`${option.name} from ${from}: ${verdict.detail}`);
198364
+ refused.push({ name: option.name, source: option.candidate?.source ?? "workflow", verdict });
198231
198365
  log2.info(`\xBB ${option.name} from ${from} unavailable; trying the next credential`);
198232
198366
  }
198233
198367
  throw new Error(
198234
- `all configured credentials for ${model} were rejected or exhausted (${refused.join("; ")}); no other model or provider was selected`
198368
+ buildCredentialPoolRefusedError({
198369
+ model,
198370
+ refused,
198371
+ owner: input.ctx.repo.owner,
198372
+ name: input.ctx.repo.name
198373
+ })
198235
198374
  );
198236
198375
  }
198237
198376
  async function probe(input) {
@@ -198272,7 +198411,7 @@ function activate(input) {
198272
198411
  import { randomUUID as randomUUID6 } from "node:crypto";
198273
198412
  import { writeFileSync as writeFileSync15 } from "node:fs";
198274
198413
  import { createServer as createServer3 } from "node:http";
198275
- import { join as join25 } from "node:path";
198414
+ import { join as join26 } from "node:path";
198276
198415
  var REVOKED_TRAP_MS = 6e4;
198277
198416
  function revokeGitHubToken(token) {
198278
198417
  fetch("https://api.github.com/installation/token", {
@@ -198341,7 +198480,7 @@ async function startGitAuthServer(tmpdir4) {
198341
198480
  function writeAskpassScript(code) {
198342
198481
  const scriptId = randomUUID6();
198343
198482
  const scriptName = `askpass-${scriptId}.js`;
198344
- const scriptPath = join25(tmpdir4, scriptName);
198483
+ const scriptPath = join26(tmpdir4, scriptName);
198345
198484
  const content = [
198346
198485
  `#!/usr/bin/env node`,
198347
198486
  `var a=process.argv[2]||"";`,
@@ -198790,7 +198929,7 @@ function resolveInstructions(ctx) {
198790
198929
 
198791
198930
  // utils/learnings.ts
198792
198931
  import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
198793
- import { dirname as dirname4, join as join26 } from "node:path";
198932
+ import { dirname as dirname4, join as join27 } from "node:path";
198794
198933
 
198795
198934
  // utils/learningsTruncate.ts
198796
198935
  var MAX_LEARNINGS_LENGTH = 1e5;
@@ -198808,10 +198947,10 @@ function truncateAtLineBoundary(body, cap) {
198808
198947
  var LEARNINGS_FILE_NAME = "pullfrog-learnings.md";
198809
198948
  var XREPO_LEARNINGS_FILE_NAME = "pullfrog-xrepo-learnings.md";
198810
198949
  function learningsFilePath(tmpdir4) {
198811
- return join26(tmpdir4, LEARNINGS_FILE_NAME);
198950
+ return join27(tmpdir4, LEARNINGS_FILE_NAME);
198812
198951
  }
198813
198952
  function xrepoLearningsFilePath(tmpdir4) {
198814
- return join26(tmpdir4, XREPO_LEARNINGS_FILE_NAME);
198953
+ return join27(tmpdir4, XREPO_LEARNINGS_FILE_NAME);
198815
198954
  }
198816
198955
  async function seedLearningsFile(params) {
198817
198956
  const path4 = learningsFilePath(params.tmpdir);
@@ -199362,7 +199501,9 @@ async function resolveTokens(params) {
199362
199501
  // commit-status check-runs for branch protection. the app already grants
199363
199502
  // checks:write; this scopes the MCP token up to use it.
199364
199503
  checks: "write",
199365
- actions: "read"
199504
+ // write (not read) so postReviewCleanup can dispatch the follow-up re-review
199505
+ // for commits pushed while a review was in flight; read 403s it.
199506
+ actions: "write"
199366
199507
  };
199367
199508
  const mcpToken = await acquireNewToken({
199368
199509
  repos: writeRepos,
@@ -199748,7 +199889,7 @@ async function resolveTrialFallback(ctx) {
199748
199889
 
199749
199890
  // utils/prSummary.ts
199750
199891
  import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
199751
- import { dirname as dirname5, join as join27 } from "node:path";
199892
+ import { dirname as dirname5, join as join28 } from "node:path";
199752
199893
  var SUMMARY_FILE_NAME = "pullfrog-summary.md";
199753
199894
  var SUMMARY_SCAFFOLD = `# PR summary
199754
199895
 
@@ -199758,7 +199899,7 @@ var SUMMARY_SCAFFOLD = `# PR summary
199758
199899
  var MIN_SNAPSHOT_LENGTH = 60;
199759
199900
  var MAX_SNAPSHOT_LENGTH = 32768;
199760
199901
  function summaryFilePath(tmpdir4) {
199761
- return join27(tmpdir4, SUMMARY_FILE_NAME);
199902
+ return join28(tmpdir4, SUMMARY_FILE_NAME);
199762
199903
  }
199763
199904
  async function seedSummaryFile(params) {
199764
199905
  const path4 = summaryFilePath(params.tmpdir);
@@ -200027,7 +200168,7 @@ function formatBillingExhaustedBody(diagnostic) {
200027
200168
 
200028
200169
  // utils/runErrorRenderer.ts
200029
200170
  function isProviderModelNotFoundError(message) {
200030
- return message.includes("ProviderModelNotFoundError");
200171
+ return /ProviderModelNotFoundError|\bModel not found: /.test(message);
200031
200172
  }
200032
200173
  function isContextOverflowError(message) {
200033
200174
  return /Prompt is too long/i.test(message) || /Input exceeds context window/i.test(message) || /maximum context length is \d+ tokens/i.test(message) || /Session too large to compact/i.test(message);
@@ -200124,6 +200265,7 @@ function detectProviderId(message) {
200124
200265
  if (/requires more credits|Key limit exceeded \(total limit\)|openrouter\.ai/i.test(message)) {
200125
200266
  return "openrouter";
200126
200267
  }
200268
+ if (/platform\.openai\.com/i.test(message)) return "openai";
200127
200269
  return null;
200128
200270
  }
200129
200271
  var OPENROUTER_PRIVACY_URL = "https://openrouter.ai/settings/privacy";
@@ -200221,12 +200363,38 @@ function formatByokSetupSummary(input) {
200221
200363
  ].join("\n");
200222
200364
  }
200223
200365
  function formatProviderModelNotFoundSummary(input) {
200224
- return `The configured model is no longer available in OpenCode's catalog. Pick a different model in the Pullfrog console for \`${input.owner}/${input.name}\`, or contact support if this persists.
200366
+ return `The configured model is not in OpenCode's catalog. Pick a different model for \`${input.owner}/${input.name}\` where it is set \u2014 the Pullfrog console, the workflow's \`model\` input, or the \`PULLFROG_MODEL\` variable \u2014 or contact support if this persists.
200225
200367
 
200226
200368
  \`\`\`
200227
200369
  ${input.raw}
200228
200370
  \`\`\``;
200229
200371
  }
200372
+ function formatProviderUsageLimit(input) {
200373
+ return [
200374
+ "**The provider's usage limit for this account has been reached.**",
200375
+ "",
200376
+ "A subscription window resets on its own, so re-trigger after it does; a limit set on a pay-as-you-go workspace has to be raised. A model on another provider can run in the meantime.",
200377
+ "",
200378
+ `[Model settings \u2192](${getApiUrl()}/console/${input.owner}/${input.name}) \xB7 [Ask in Discord \u2192](https://discord.gg/8y96raFg8e)`,
200379
+ "",
200380
+ `\`\`\`
200381
+ ${input.raw}
200382
+ \`\`\``
200383
+ ].join("\n");
200384
+ }
200385
+ function formatProviderErrorBody(input) {
200386
+ return [
200387
+ "**The model provider returned an error, so this run stopped.**",
200388
+ "",
200389
+ "The error is below. If it names your key, plan or balance, fix that with the provider or pick another model before re-triggering.",
200390
+ "",
200391
+ `[Model settings \u2192](${getApiUrl()}/console/${input.owner}/${input.name}) \xB7 [Ask in Discord \u2192](https://discord.gg/8y96raFg8e)`,
200392
+ "",
200393
+ `\`\`\`
200394
+ ${input.raw}
200395
+ \`\`\``
200396
+ ].join("\n");
200397
+ }
200230
200398
  function renderRunError(input) {
200231
200399
  const billingError = input.routerActive && isRouterKeylimitExhaustedError(input.errorMessage) ? new BillingError(input.errorMessage, { code: "router_keylimit_exhausted" }) : null;
200232
200400
  if (billingError) {
@@ -200242,7 +200410,7 @@ function renderRunError(input) {
200242
200410
  if (input.errorMessage.includes(ROUTER_UNFUNDED_MARKER)) {
200243
200411
  return { summary: input.errorMessage, comment: input.errorMessage };
200244
200412
  }
200245
- if (input.errorMessage.includes(CREDENTIAL_REJECTED_MARKER)) {
200413
+ if (input.errorMessage.includes(CREDENTIAL_REJECTED_MARKER) || input.errorMessage.includes(CREDENTIAL_POOL_REFUSED_MARKER)) {
200246
200414
  return { summary: input.errorMessage, comment: input.errorMessage };
200247
200415
  }
200248
200416
  const isHang = input.errorMessage.startsWith("activity timeout") || input.errorMessage.startsWith("agent still pending");
@@ -200265,6 +200433,16 @@ ${body}`, comment: body };
200265
200433
  });
200266
200434
  return { summary: `### \u274C Pullfrog failed
200267
200435
 
200436
+ ${body}`, comment: body };
200437
+ }
200438
+ if (isProviderUsageLimit(input.errorMessage)) {
200439
+ const body = formatProviderUsageLimit({
200440
+ owner: input.repo.owner,
200441
+ name: input.repo.name,
200442
+ raw: input.errorMessage
200443
+ });
200444
+ return { summary: `### \u274C Pullfrog failed
200445
+
200268
200446
  ${body}`, comment: body };
200269
200447
  }
200270
200448
  if (isProviderMissingCredential(input.errorMessage)) {
@@ -200359,6 +200537,16 @@ ${hangBody}`,
200359
200537
  comment: explained ? hangBody : formatMinimalFailureComment(input.repo)
200360
200538
  };
200361
200539
  }
200540
+ if (input.errorMessage.startsWith("provider error: ")) {
200541
+ const body = formatProviderErrorBody({
200542
+ owner: input.repo.owner,
200543
+ name: input.repo.name,
200544
+ raw: input.errorMessage
200545
+ });
200546
+ return { summary: `### \u274C Pullfrog failed
200547
+
200548
+ ${body}`, comment: body };
200549
+ }
200362
200550
  const genericBody = formatGenericFailure(input.errorMessage);
200363
200551
  return {
200364
200552
  summary: `### \u274C Pullfrog failed
@@ -201169,7 +201357,7 @@ async function main() {
201169
201357
  const repoDir = payload.cwd;
201170
201358
  const preIntrospectionDirty = repoDir ? await dirtyTrackedPaths({ cwd: repoDir }) : null;
201171
201359
  const opencodeCliPath = await agents.opencode.install();
201172
- captureBaselineModels(opencodeCliPath);
201360
+ captureBaselineModels({ cliPath: opencodeCliPath, tmpdir: tmpdir4 });
201173
201361
  const injected = [];
201174
201362
  if (runContext.dbSecrets) {
201175
201363
  for (const [key, value2] of Object.entries(runContext.dbSecrets)) {
@@ -201186,7 +201374,7 @@ async function main() {
201186
201374
  await initializeCredentialPool({ ctx: runContext, slug: payload.model, injected });
201187
201375
  installCodexAuth();
201188
201376
  installXaiAuth();
201189
- captureAuthorizedModels(opencodeCliPath);
201377
+ captureAuthorizedModels({ cliPath: opencodeCliPath, tmpdir: tmpdir4 });
201190
201378
  if (preIntrospectionDirty && repoDir) {
201191
201379
  await restoreDirtiedSince({
201192
201380
  before: preIntrospectionDirty,
@@ -201524,8 +201712,8 @@ ${instructions.user}` : null,
201524
201712
  log2.info(instructions.full);
201525
201713
  });
201526
201714
  if (agentId === "opencode") {
201527
- const pluginDir = join28(process.cwd(), ".opencode", "plugin");
201528
- const hasPlugins = existsSync12(pluginDir) && readdirSync2(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
201715
+ const pluginDir = join29(process.cwd(), ".opencode", "plugin");
201716
+ const hasPlugins = existsSync13(pluginDir) && readdirSync2(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
201529
201717
  if (hasPlugins && toolState.dependencyInstallation?.promise) {
201530
201718
  log2.info(
201531
201719
  "\xBB .opencode/plugin/ detected \u2014 awaiting dependency installation before agent start"
@@ -202134,7 +202322,7 @@ async function run2() {
202134
202322
  // commands/_prEvents.ts
202135
202323
  import { mkdirSync as mkdirSync11, readFileSync as readFileSync10, writeFileSync as writeFileSync16 } from "node:fs";
202136
202324
  import { homedir as homedir3 } from "node:os";
202137
- import { join as join29 } from "node:path";
202325
+ import { join as join30 } from "node:path";
202138
202326
  var REQUEST_TIMEOUT_MS = 35e3;
202139
202327
  var SERVER_POLL_WINDOW_MS = 2e4;
202140
202328
  var PrEventsAuthError = class extends Error {
@@ -202183,11 +202371,11 @@ async function pollPrEvents(ctx) {
202183
202371
  }
202184
202372
  }
202185
202373
  function stateDir() {
202186
- return process.env.PULLFROG_STATE_DIR || join29(homedir3(), ".pullfrog");
202374
+ return process.env.PULLFROG_STATE_DIR || join30(homedir3(), ".pullfrog");
202187
202375
  }
202188
202376
  function cursorPath(target) {
202189
202377
  const key = [target.owner, target.repo, String(target.pr)].map(encodeURIComponent).join("__");
202190
- return join29(stateDir(), "watch", `${key}.cursor`);
202378
+ return join30(stateDir(), "watch", `${key}.cursor`);
202191
202379
  }
202192
202380
  function readCursor(target) {
202193
202381
  try {
@@ -202200,7 +202388,7 @@ function readCursor(target) {
202200
202388
  function writeCursor(target, cursor3) {
202201
202389
  try {
202202
202390
  const path4 = cursorPath(target);
202203
- mkdirSync11(join29(stateDir(), "watch"), { recursive: true });
202391
+ mkdirSync11(join30(stateDir(), "watch"), { recursive: true });
202204
202392
  writeFileSync16(path4, cursor3, "utf-8");
202205
202393
  } catch {
202206
202394
  }
@@ -202255,7 +202443,7 @@ function resolveTarget2(repo) {
202255
202443
  return { owner: match3[1], repo: match3[2] };
202256
202444
  }
202257
202445
  function cliVersion() {
202258
- const parts = /^(\d+)\.(\d+)\.(\d+)$/.exec("0.1.96");
202446
+ const parts = /^(\d+)\.(\d+)\.(\d+)$/.exec("0.1.97");
202259
202447
  if (!parts) return "0.0.0";
202260
202448
  return `${Number(parts[1])}.${Number(parts[2])}.${Number(parts[3])}`;
202261
202449
  }
@@ -202422,7 +202610,7 @@ async function runCli8(input) {
202422
202610
  }
202423
202611
 
202424
202612
  // cli.ts
202425
- var VERSION10 = "0.1.96";
202613
+ var VERSION10 = "0.1.97";
202426
202614
  var bin = basename2(process.argv[1] || "");
202427
202615
  var PROG = bin === "pf" || bin === "pullfrog" ? bin : "pullfrog";
202428
202616
  var rawArgs = process.argv.slice(2);