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/index.js CHANGED
@@ -98088,14 +98088,14 @@ var require_turndown_cjs = __commonJS({
98088
98088
  } else if (node2.nodeType === 1) {
98089
98089
  replacement = replacementForNode.call(self2, node2);
98090
98090
  }
98091
- return join28(output, replacement);
98091
+ return join29(output, replacement);
98092
98092
  }, "");
98093
98093
  }
98094
98094
  function postProcess(output) {
98095
98095
  var self2 = this;
98096
98096
  this.rules.forEach(function(rule) {
98097
98097
  if (typeof rule.append === "function") {
98098
- output = join28(output, rule.append(self2.options));
98098
+ output = join29(output, rule.append(self2.options));
98099
98099
  }
98100
98100
  });
98101
98101
  return output.replace(/^[\t\r\n]+/, "").replace(/[\t\r\n\s]+$/, "");
@@ -98107,7 +98107,7 @@ var require_turndown_cjs = __commonJS({
98107
98107
  if (whitespace.leading || whitespace.trailing) content = content.trim();
98108
98108
  return whitespace.leading + rule.replacement(content, node2, this.options) + whitespace.trailing;
98109
98109
  }
98110
- function join28(output, replacement) {
98110
+ function join29(output, replacement) {
98111
98111
  var s1 = trimTrailingNewlines(output);
98112
98112
  var s2 = trimLeadingNewlines(replacement);
98113
98113
  var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
@@ -102898,9 +102898,9 @@ var init_file_type = __esm({
102898
102898
  });
102899
102899
 
102900
102900
  // main.ts
102901
- import { existsSync as existsSync10, readdirSync as readdirSync2 } from "node:fs";
102901
+ import { existsSync as existsSync11, readdirSync as readdirSync2 } from "node:fs";
102902
102902
  import { readFile as readFile5 } from "node:fs/promises";
102903
- import { join as join27 } from "node:path";
102903
+ import { join as join28 } from "node:path";
102904
102904
 
102905
102905
  // node_modules/.pnpm/package-manager-detector@1.8.0/node_modules/package-manager-detector/dist/commands.mjs
102906
102906
  function splitRunArgs(args2, valueFlags = []) {
@@ -103909,6 +103909,15 @@ var providers = {
103909
103909
  openRouterEffort: [],
103910
103910
  openRouterResolve: "openrouter/qwen/qwen3.8-flash"
103911
103911
  },
103912
+ // Zen lists Mistral Large 4 at half its list price for launch, level with
103913
+ // the routers. hidden until a published `pullfrog` resolves it, as there.
103914
+ "mistral-large": {
103915
+ displayName: "Mistral Large",
103916
+ resolve: "opencode/mistral-large-4",
103917
+ effort: ["none", "high"],
103918
+ openRouterResolve: "openrouter/mistralai/mistral-large-4-0",
103919
+ hidden: true
103920
+ },
103912
103921
  "gpt-5-nano": {
103913
103922
  displayName: "GPT Nano",
103914
103923
  resolve: "opencode/gpt-5.4-nano",
@@ -103957,12 +103966,12 @@ var providers = {
103957
103966
  fallback: "opencode/big-pickle"
103958
103967
  },
103959
103968
  // Zen's FREE stealth preview (listed 2026-09-23), the third after
103960
- // big-pickle and union-alpha. OpenRouter's listing: prompts and
103961
- // completions may be retained by the provider, not trained on. unlike the
103962
- // first two it publishes an effort ladder, so the gate mirrors one here.
103969
+ // big-pickle and union-alpha. Zen's docs: zero retention, no training.
103970
+ // unlike the first two it publishes an effort ladder, so the gate
103971
+ // mirrors one here.
103963
103972
  "space-bunny": {
103964
103973
  displayName: "Space Bunny",
103965
- description: "Stealth preview; prompts may be retained",
103974
+ description: "Stealth preview, free for a limited time",
103966
103975
  resolve: "opencode/space-bunny-free",
103967
103976
  effort: ["low", "medium", "high", "xhigh", "max"],
103968
103977
  // free to run, still gated on the provider's own OPENCODE_API_KEY —
@@ -103980,17 +103989,30 @@ var providers = {
103980
103989
  // see the big-pickle note above (#1077).
103981
103990
  isFree: true
103982
103991
  },
103983
- // Zen's FREE stealth preview (listed 2026-10-01), the fifth promo. Zen
103984
- // has not published its data terms yet, so the description claims none.
103992
+ // Zen's FREE stealth preview (listed 2026-10-01), the fifth promo. Zen's
103993
+ // docs: data may be used to improve the model during the free period.
103985
103994
  "fledge-alpha": {
103986
103995
  displayName: "Fledge Alpha",
103987
- description: "Stealth preview, free for a limited time",
103996
+ description: "Stealth preview; data may be used to improve it",
103988
103997
  resolve: "opencode/fledge-alpha-free",
103989
103998
  effort: ["low", "high", "max"],
103990
103999
  // free to run, still gated on the provider's own OPENCODE_API_KEY —
103991
104000
  // see the big-pickle note above (#1077).
103992
104001
  isFree: true
103993
104002
  },
104003
+ // Zen's FREE preview (listed 2026-10-06), the sixth promo, on Fledge
104004
+ // Alpha's terms. hidden until a published `pullfrog` resolves it.
104005
+ exo: {
104006
+ displayName: "Exo",
104007
+ description: "Preview; data may be used to improve it",
104008
+ resolve: "opencode/exo-free",
104009
+ // Zen publishes a single rung.
104010
+ effort: ["high"],
104011
+ // free to run, still gated on the provider's own OPENCODE_API_KEY —
104012
+ // see the big-pickle note above (#1077).
104013
+ isFree: true,
104014
+ hidden: true
104015
+ },
103994
104016
  // Zen's live free MiMo, and the second free row in a menu that big-pickle
103995
104017
  // was alone in since `mimo-v2-pro-free` lost its model.
103996
104018
  mimo: {
@@ -104446,6 +104468,17 @@ var providers = {
104446
104468
  resolve: "openrouter/meta/muse-spark-1.3-contributor",
104447
104469
  effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
104448
104470
  openRouterResolve: "openrouter/meta/muse-spark-1.3-contributor"
104471
+ },
104472
+ // Mistral's flagship (public preview, 2026-10-06), on the routers only:
104473
+ // the catalog carries no direct Mistral provider. out of the pickers
104474
+ // until a published `pullfrog` resolves the slug; see "A new alias
104475
+ // reaches the picker at merge".
104476
+ "mistral-large": {
104477
+ displayName: "Mistral Large",
104478
+ resolve: "openrouter/mistralai/mistral-large-4-0",
104479
+ effort: ["none", "high"],
104480
+ openRouterResolve: "openrouter/mistralai/mistral-large-4-0",
104481
+ hidden: true
104449
104482
  }
104450
104483
  }
104451
104484
  }),
@@ -104537,6 +104570,13 @@ var providers = {
104537
104570
  displayName: "Muse Spark",
104538
104571
  resolve: "vercel/meta/muse-spark-1.3",
104539
104572
  effort: ["minimal", "low", "medium", "high", "xhigh"]
104573
+ },
104574
+ // hidden until a published `pullfrog` resolves it, as on OpenRouter.
104575
+ "mistral-large": {
104576
+ displayName: "Mistral Large",
104577
+ resolve: "vercel/mistral/mistral-large-4",
104578
+ effort: ["none", "low", "medium", "high"],
104579
+ hidden: true
104540
104580
  }
104541
104581
  }
104542
104582
  })
@@ -105417,7 +105457,13 @@ var PROVIDER_ERROR_PATTERNS = [
105417
105457
  // …/settings/credits`) matched neither this nor `requires more credits`, so
105418
105458
  // the one run in that episode that was a genuinely empty wallet rendered with
105419
105459
  // no CTA at all while runs 1-6 rendered correctly.
105420
- { regex: /Insufficient (?:balance|credits)/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
105460
+ // `account funds` is Zen's (#1474).
105461
+ {
105462
+ regex: /Insufficient (?:balance|credits|account funds)/i,
105463
+ label: PROVIDER_BILLING_EXHAUSTED_LABEL
105464
+ },
105465
+ // OpenAI's `You have no credits remaining` (#1474).
105466
+ { regex: /no credits remaining/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
105421
105467
  { regex: /credit balance is too low/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
105422
105468
  // "spending cap" (Gemini) and "spending limit" (xAI, #1076) are the same
105423
105469
  // condition in two house styles — a configured ceiling was reached.
@@ -105538,6 +105584,9 @@ function isRouterKeylimitExhaustedError(text) {
105538
105584
  function isProviderBillingExhausted(text) {
105539
105585
  return findProviderErrorMatch(text)?.label === PROVIDER_BILLING_EXHAUSTED_LABEL;
105540
105586
  }
105587
+ function isProviderUsageLimit(text) {
105588
+ return findProviderErrorMatch(text)?.label === PROVIDER_USAGE_LIMIT_LABEL;
105589
+ }
105541
105590
  function isProviderNoRoutableEndpoints(text) {
105542
105591
  return findProviderErrorMatch(text)?.label === PROVIDER_NO_ENDPOINTS_LABEL;
105543
105592
  }
@@ -105609,7 +105658,7 @@ var import_semver = __toESM(require_semver2(), 1);
105609
105658
  // package.json
105610
105659
  var package_default = {
105611
105660
  name: "pullfrog",
105612
- version: "0.1.96",
105661
+ version: "0.1.97",
105613
105662
  cliContractVersion: "1",
105614
105663
  type: "module",
105615
105664
  bin: {
@@ -105906,6 +105955,7 @@ ${this.buffer}`;
105906
105955
  };
105907
105956
  async function spawn(options) {
105908
105957
  const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
105958
+ options.signal?.throwIfAborted();
105909
105959
  installSignalHandler();
105910
105960
  const startTime = performance3.now();
105911
105961
  const retain = options.retain ?? "tail";
@@ -105941,17 +105991,18 @@ async function spawn(options) {
105941
105991
  let isActivityTimedOut = false;
105942
105992
  let lastActivityTime = performance3.now();
105943
105993
  let killedAtIdleMs;
105994
+ const terminate = () => {
105995
+ if (sigkillEscalatorId) return;
105996
+ killSelf("SIGTERM");
105997
+ sigkillEscalatorId = setTimeout(() => killSelf("SIGKILL"), 5e3);
105998
+ };
105944
105999
  if (options.timeout) {
105945
106000
  timeoutId = setTimeout(() => {
105946
106001
  isTimedOut = true;
105947
- killSelf("SIGTERM");
105948
- sigkillEscalatorId = setTimeout(() => {
105949
- if (!child.killed) {
105950
- killSelf("SIGKILL");
105951
- }
105952
- }, 5e3);
106002
+ terminate();
105953
106003
  }, options.timeout);
105954
106004
  }
106005
+ options.signal?.addEventListener("abort", terminate, { once: true });
105955
106006
  if (activityTimeoutMs > 0) {
105956
106007
  log.debug(
105957
106008
  `spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms`
@@ -106016,6 +106067,11 @@ async function spawn(options) {
106016
106067
  if (timeoutId) clearTimeout(timeoutId);
106017
106068
  if (sigkillEscalatorId) clearTimeout(sigkillEscalatorId);
106018
106069
  if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
106070
+ options.signal?.removeEventListener("abort", terminate);
106071
+ if (options.signal?.aborted) {
106072
+ reject(options.signal.reason);
106073
+ return;
106074
+ }
106019
106075
  if (isTimedOut) {
106020
106076
  reject(
106021
106077
  new SpawnTimeoutError(`process timed out after ${options.timeout}ms`, SPAWN_TIMEOUT_CODE)
@@ -106054,6 +106110,7 @@ ${killMsg}` : killMsg;
106054
106110
  if (timeoutId) clearTimeout(timeoutId);
106055
106111
  if (sigkillEscalatorId) clearTimeout(sigkillEscalatorId);
106056
106112
  if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
106113
+ options.signal?.removeEventListener("abort", terminate);
106057
106114
  const errMsg = `[spawn] ${options.cmd}: ${error49.message}`;
106058
106115
  console.error(errMsg);
106059
106116
  const existingStderr = stderrBuffer?.toString() ?? "";
@@ -106295,17 +106352,16 @@ Report findings clearly with file:line references and quoted evidence where poss
106295
106352
  // modes.ts
106296
106353
  var PR_SUMMARY_FORMAT = `### Default format
106297
106354
 
106298
- The body has at most three parts, in this order:
106355
+ The body has at most two parts, in this order:
106299
106356
 
106300
106357
  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.
106301
106358
  2. **Cross-cutting issue sections** (zero or more) \u2014 one \`### {emoji} {what's wrong, not what to do}\` heading per concern.
106302
- 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.
106303
106359
 
106304
106360
  **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.
106305
106361
 
106306
106362
  **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).
106307
106363
 
106308
- **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.
106364
+ **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.
106309
106365
 
106310
106366
  **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.
106311
106367
 
@@ -106454,8 +106510,6 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
106454
106510
 
106455
106511
  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.
106456
106512
 
106457
- 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.
106458
-
106459
106513
  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.
106460
106514
 
106461
106515
  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.
@@ -106479,8 +106533,6 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
106479
106533
 
106480
106534
  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.
106481
106535
 
106482
- **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.
106483
-
106484
106536
  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.
106485
106537
 
106486
106538
  7. **submit**: ALWAYS submit exactly one review via \`${t("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.
@@ -106502,7 +106554,7 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
106502
106554
  \`approved: false\`. Body opens with \`> [!CAUTION]\\n> This PR introduces ...\`, followed by the PR summary. Include all inline comments via \`comments\`.
106503
106555
  - **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):
106504
106556
  \`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\`.
106505
- - **minor suggestions only** (low-stakes findings, or Nitpicks only):
106557
+ - **minor suggestions only** (low-stakes findings):
106506
106558
  \`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.
106507
106559
  - **informational observations** (mergeable as-is, nothing actionable \u2014 e.g. prior feedback addressed cleanly, calling out something noteworthy without recommending a change):
106508
106560
  \`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.
@@ -106545,8 +106597,6 @@ ${PR_SUMMARY_FORMAT}`
106545
106597
 
106546
106598
  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.**
106547
106599
 
106548
- 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).
106549
-
106550
106600
  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.
106551
106601
 
106552
106602
  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.
@@ -106570,11 +106620,9 @@ ${PR_SUMMARY_FORMAT}`
106570
106620
 
106571
106621
  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 \`${t("list_pull_request_reviews")}\` in step 4) and run \`git diff <prior-review-sha>..HEAD\` to isolate the lines added since that review.
106572
106622
 
106573
- **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.
106574
-
106575
106623
  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.
106576
106624
 
106577
- 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.
106625
+ 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.
106578
106626
 
106579
106627
  10. Submit \u2014 every run must end with EXACTLY ONE of \`${t("create_pull_request_review")}\` (substantive review) or \`${t("report_progress")}\` (no-review acknowledgement). do NOT call \`create_issue_comment\` for review output.
106580
106628
 
@@ -106585,7 +106633,7 @@ ${PR_SUMMARY_FORMAT}`
106585
106633
  - IF NO NEW ISSUES, NON-SUBSTANTIVE CHANGES ONLY (trivial formatting, import reordering, comment tweaks): do NOT submit a review. Instead call \`${t("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.
106586
106634
  - ELSE IF NEW CRITICAL ISSUES (blocks merge \u2014 bugs, security, data loss, broken core flows): call \`${t("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.
106587
106635
  - 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 \`${t("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.
106588
- - ELSE IF NEW MINOR SUGGESTIONS ONLY (low-stakes findings, or Nitpicks only): call \`${t("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.
106636
+ - ELSE IF NEW MINOR SUGGESTIONS ONLY (low-stakes findings): call \`${t("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.
106589
106637
  - ELSE IF INFORMATIONAL OBSERVATIONS (mergeable as-is, but worth surfacing \u2014 e.g. prior feedback addressed cleanly, or a noteworthy positive observation): call \`${t("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.
106590
106638
  - ELSE IF NO NEW ISSUES, SUBSTANTIVE CHANGES (new functionality, behavior changes, or fixes to prior review feedback): call \`${t("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.
106591
106639
 
@@ -107802,7 +107850,7 @@ import { performance as performance6 } from "node:perf_hooks";
107802
107850
 
107803
107851
  // utils/codexHome.ts
107804
107852
  import { execFileSync as execFileSync3 } from "node:child_process";
107805
- import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync2, writeFileSync as writeFileSync4 } from "node:fs";
107853
+ import { existsSync as existsSync4, mkdirSync as mkdirSync5, mkdtempSync, readFileSync as readFileSync2, writeFileSync as writeFileSync4 } from "node:fs";
107806
107854
  import { homedir as homedir2, userInfo } from "node:os";
107807
107855
  import { join as join5 } from "node:path";
107808
107856
 
@@ -107853,205 +107901,6 @@ function stringifyCodexAuthBody(body) {
107853
107901
  `;
107854
107902
  }
107855
107903
 
107856
- // utils/xaiOAuth.ts
107857
- function parseXaiAuthBody(raw2) {
107858
- let parsed2;
107859
- try {
107860
- parsed2 = JSON.parse(raw2);
107861
- } catch {
107862
- return null;
107863
- }
107864
- if (!parsed2 || typeof parsed2 !== "object") return null;
107865
- const v = parsed2;
107866
- if (v.auth_mode !== "grok") return null;
107867
- const tokens = v.tokens;
107868
- if (!tokens || typeof tokens !== "object") return null;
107869
- const t = tokens;
107870
- if (typeof t.access_token !== "string" || t.access_token.length === 0) return null;
107871
- if (typeof t.refresh_token !== "string" || t.refresh_token.length === 0) return null;
107872
- return {
107873
- auth_mode: "grok",
107874
- ...typeof v.refresh_rejected_at === "string" ? { refresh_rejected_at: v.refresh_rejected_at } : {},
107875
- tokens: { access_token: t.access_token, refresh_token: t.refresh_token },
107876
- ...typeof v.last_refresh === "string" ? { last_refresh: v.last_refresh } : {}
107877
- };
107878
- }
107879
-
107880
- // utils/codexHome.ts
107881
- var CODEX_AUTH_ENV = "CODEX_AUTH_JSON";
107882
- var XAI_AUTH_ENV = "GROK_AUTH_JSON";
107883
- var installedSubscriptionPaths = /* @__PURE__ */ new Map();
107884
- function clearInstalledSubscription(name) {
107885
- const path4 = installedSubscriptionPaths.get(name);
107886
- if (!path4 || !existsSync4(path4)) return;
107887
- const value2 = JSON.parse(readFileSync2(path4, "utf8"));
107888
- if (!value2 || typeof value2 !== "object") return;
107889
- if (name === "CODEX_AUTH_JSON" && "openai" in value2) delete value2.openai;
107890
- if (name === "GROK_AUTH_JSON" && "xai" in value2) delete value2.xai;
107891
- writeFileSync4(path4, `${JSON.stringify(value2)}
107892
- `, { mode: 384 });
107893
- installedSubscriptionPaths.delete(name);
107894
- }
107895
- var PULLFROG_DATA_DIR = "/var/lib/pullfrog";
107896
- function isRejectedChain(body) {
107897
- if (!body.refresh_rejected_at) return false;
107898
- log.warning(
107899
- `\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.`
107900
- );
107901
- return true;
107902
- }
107903
- function installCodexAuth() {
107904
- const raw2 = process.env[CODEX_AUTH_ENV];
107905
- if (!raw2) return null;
107906
- const body = parseCodexAuthBody(raw2);
107907
- if (!body) {
107908
- log.warning(`\xBB ${CODEX_AUTH_ENV} present but malformed; ignoring`);
107909
- return null;
107910
- }
107911
- if (isRejectedChain(body)) return null;
107912
- const expiresMs = decodeJwtExpMs(body.tokens.access_token) ?? 0;
107913
- const xdgDataHome = resolveDataHome();
107914
- if (!xdgDataHome) return null;
107915
- const opencodeDir = join5(xdgDataHome, "opencode");
107916
- const authPath = join5(opencodeDir, "auth.json");
107917
- writeOpenCodeAuthEntry({
107918
- opencodeDir,
107919
- authPath,
107920
- provider: "openai",
107921
- entry: {
107922
- type: "oauth",
107923
- refresh: body.tokens.refresh_token,
107924
- access: body.tokens.access_token,
107925
- expires: expiresMs,
107926
- ...body.tokens.account_id ? { accountId: body.tokens.account_id } : {}
107927
- }
107928
- });
107929
- process.env.XDG_DATA_HOME = xdgDataHome;
107930
- log.info(`\xBB installed Codex auth at ${authPath}`);
107931
- installedSubscriptionPaths.set(CODEX_AUTH_ENV, authPath);
107932
- return {
107933
- authPath,
107934
- xdgDataHome,
107935
- originalRefresh: body.tokens.refresh_token,
107936
- originalIdToken: body.tokens.id_token
107937
- };
107938
- }
107939
- function writeOpenCodeAuthEntry(params) {
107940
- let existing = {};
107941
- if (existsSync4(params.authPath)) {
107942
- try {
107943
- const parsed2 = JSON.parse(readFileSync2(params.authPath, "utf8"));
107944
- if (parsed2 && typeof parsed2 === "object") existing = parsed2;
107945
- } catch {
107946
- log.warning(`\xBB ${params.authPath} was unreadable; rewriting it`);
107947
- }
107948
- }
107949
- const merged = { ...existing, [params.provider]: params.entry };
107950
- mkdirSync5(params.opencodeDir, { recursive: true });
107951
- writeFileSync4(params.authPath, `${JSON.stringify(merged, null, 2)}
107952
- `, { mode: 384 });
107953
- }
107954
- function installXaiAuth() {
107955
- const raw2 = process.env[XAI_AUTH_ENV];
107956
- if (!raw2) return null;
107957
- const body = parseXaiAuthBody(raw2);
107958
- if (!body) {
107959
- log.warning(`\xBB ${XAI_AUTH_ENV} present but malformed; ignoring`);
107960
- return null;
107961
- }
107962
- if (isXaiRejectedChain(body)) return null;
107963
- const xdgDataHome = resolveDataHome();
107964
- if (!xdgDataHome) return null;
107965
- const opencodeDir = join5(xdgDataHome, "opencode");
107966
- const authPath = join5(opencodeDir, "auth.json");
107967
- writeOpenCodeAuthEntry({
107968
- opencodeDir,
107969
- authPath,
107970
- provider: "xai",
107971
- // opencode refreshes when `expires` is inside its skew window, and falls
107972
- // back to the JWT's own exp when the stored value is absent. Decoding it
107973
- // here means the first request uses the token we already hold instead of
107974
- // burning a rotation on startup.
107975
- entry: {
107976
- type: "oauth",
107977
- refresh: body.tokens.refresh_token,
107978
- access: body.tokens.access_token,
107979
- expires: decodeJwtExpMs(body.tokens.access_token) ?? 0
107980
- }
107981
- });
107982
- process.env.XDG_DATA_HOME = xdgDataHome;
107983
- log.info(`\xBB installed Grok auth at ${authPath}`);
107984
- installedSubscriptionPaths.set(XAI_AUTH_ENV, authPath);
107985
- return { authPath, xdgDataHome, originalRefresh: body.tokens.refresh_token };
107986
- }
107987
- function isXaiRejectedChain(body) {
107988
- if (!body.refresh_rejected_at) return false;
107989
- log.warning(
107990
- `\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.`
107991
- );
107992
- return true;
107993
- }
107994
- function installCodexHome() {
107995
- const raw2 = process.env[CODEX_AUTH_ENV];
107996
- if (!raw2) return null;
107997
- const body = parseCodexAuthBody(raw2);
107998
- if (!body) {
107999
- log.warning(`\xBB ${CODEX_AUTH_ENV} present but malformed; ignoring`);
108000
- return null;
108001
- }
108002
- if (isRejectedChain(body)) return null;
108003
- if (!body.tokens.id_token) {
108004
- log.warning(
108005
- `\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.`
108006
- );
108007
- return null;
108008
- }
108009
- const root = resolveDataHome();
108010
- if (!root) return null;
108011
- const codexHome = join5(root, "codex");
108012
- const authPath = join5(codexHome, "auth.json");
108013
- mkdirSync5(codexHome, { recursive: true });
108014
- writeFileSync4(authPath, stringifyCodexAuthBody(body), { mode: 384 });
108015
- log.info(`\xBB installed Codex auth at ${authPath}`);
108016
- return { codexHome, authPath, originalRefresh: body.tokens.refresh_token };
108017
- }
108018
- var dataHome;
108019
- var unhostedSubscriptions = /* @__PURE__ */ new Set();
108020
- function canInstallSubscription(name) {
108021
- return name !== CODEX_AUTH_ENV && name !== XAI_AUTH_ENV || resolveDataHome() !== null;
108022
- }
108023
- function resolveDataHome() {
108024
- if (process.env.CI !== "true") return join5(homedir2(), ".local", "share");
108025
- if (dataHome === void 0) dataHome = bootstrapPullfrogDataDir();
108026
- return dataHome;
108027
- }
108028
- function bootstrapPullfrogDataDir() {
108029
- const user = userInfo().username;
108030
- let primaryGroup;
108031
- try {
108032
- primaryGroup = execFileSync3("id", ["-gn", user], { stdio: "pipe", encoding: "utf-8" }).trim();
108033
- } catch {
108034
- primaryGroup = user;
108035
- }
108036
- try {
108037
- execFileSync3("sudo", ["-n", "mkdir", "-p", PULLFROG_DATA_DIR], { stdio: "pipe" });
108038
- execFileSync3("sudo", ["-n", "chown", `${user}:${primaryGroup}`, PULLFROG_DATA_DIR], {
108039
- stdio: "pipe"
108040
- });
108041
- execFileSync3("sudo", ["-n", "chmod", "700", PULLFROG_DATA_DIR], { stdio: "pipe" });
108042
- } catch (err) {
108043
- log.warning(
108044
- `\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.`
108045
- );
108046
- for (const name of [CODEX_AUTH_ENV, XAI_AUTH_ENV]) {
108047
- if (process.env[name]) unhostedSubscriptions.add(name);
108048
- delete process.env[name];
108049
- }
108050
- return null;
108051
- }
108052
- return PULLFROG_DATA_DIR;
108053
- }
108054
-
108055
107904
  // utils/oauthWriteback.ts
108056
107905
  var core2 = __toESM(require_core(), 1);
108057
107906
 
@@ -110281,6 +110130,7 @@ async function apiFetch(options) {
110281
110130
 
110282
110131
  // utils/oauthWriteback.ts
110283
110132
  var OAUTH_WRITEBACK_STATE = "oauth_writeback";
110133
+ var SUBSCRIPTION_DATA_DIR_STATE = "subscription_data_dir";
110284
110134
 
110285
110135
  // utils/secretCommands.ts
110286
110136
  var core3 = __toESM(require_core(), 1);
@@ -110291,6 +110141,212 @@ function saveSecretState(name, value2) {
110291
110141
  if (isGitHubActions) core3.saveState(name, value2);
110292
110142
  }
110293
110143
 
110144
+ // utils/xaiOAuth.ts
110145
+ function parseXaiAuthBody(raw2) {
110146
+ let parsed2;
110147
+ try {
110148
+ parsed2 = JSON.parse(raw2);
110149
+ } catch {
110150
+ return null;
110151
+ }
110152
+ if (!parsed2 || typeof parsed2 !== "object") return null;
110153
+ const v = parsed2;
110154
+ if (v.auth_mode !== "grok") return null;
110155
+ const tokens = v.tokens;
110156
+ if (!tokens || typeof tokens !== "object") return null;
110157
+ const t = tokens;
110158
+ if (typeof t.access_token !== "string" || t.access_token.length === 0) return null;
110159
+ if (typeof t.refresh_token !== "string" || t.refresh_token.length === 0) return null;
110160
+ return {
110161
+ auth_mode: "grok",
110162
+ ...typeof v.refresh_rejected_at === "string" ? { refresh_rejected_at: v.refresh_rejected_at } : {},
110163
+ tokens: { access_token: t.access_token, refresh_token: t.refresh_token },
110164
+ ...typeof v.last_refresh === "string" ? { last_refresh: v.last_refresh } : {}
110165
+ };
110166
+ }
110167
+
110168
+ // utils/codexHome.ts
110169
+ var CODEX_AUTH_ENV = "CODEX_AUTH_JSON";
110170
+ var XAI_AUTH_ENV = "GROK_AUTH_JSON";
110171
+ var installedSubscriptionPaths = /* @__PURE__ */ new Map();
110172
+ var installedDataHome;
110173
+ function getSubscriptionDataHome() {
110174
+ return installedDataHome;
110175
+ }
110176
+ function clearInstalledSubscription(name) {
110177
+ const path4 = installedSubscriptionPaths.get(name);
110178
+ if (!path4 || !existsSync4(path4)) return;
110179
+ const value2 = JSON.parse(readFileSync2(path4, "utf8"));
110180
+ if (!value2 || typeof value2 !== "object") return;
110181
+ if (name === "CODEX_AUTH_JSON" && "openai" in value2) delete value2.openai;
110182
+ if (name === "GROK_AUTH_JSON" && "xai" in value2) delete value2.xai;
110183
+ writeFileSync4(path4, `${JSON.stringify(value2)}
110184
+ `, { mode: 384 });
110185
+ installedSubscriptionPaths.delete(name);
110186
+ }
110187
+ var PULLFROG_DATA_DIR = "/var/lib/pullfrog";
110188
+ function isRejectedChain(body) {
110189
+ if (!body.refresh_rejected_at) return false;
110190
+ log.warning(
110191
+ `\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.`
110192
+ );
110193
+ return true;
110194
+ }
110195
+ function installCodexAuth() {
110196
+ const raw2 = process.env[CODEX_AUTH_ENV];
110197
+ if (!raw2) return null;
110198
+ const body = parseCodexAuthBody(raw2);
110199
+ if (!body) {
110200
+ log.warning(`\xBB ${CODEX_AUTH_ENV} present but malformed; ignoring`);
110201
+ return null;
110202
+ }
110203
+ if (isRejectedChain(body)) return null;
110204
+ const expiresMs = decodeJwtExpMs(body.tokens.access_token) ?? 0;
110205
+ const xdgDataHome = resolveDataHome();
110206
+ if (!xdgDataHome) return null;
110207
+ const opencodeDir = join5(xdgDataHome, "opencode");
110208
+ const authPath = join5(opencodeDir, "auth.json");
110209
+ writeOpenCodeAuthEntry({
110210
+ opencodeDir,
110211
+ authPath,
110212
+ provider: "openai",
110213
+ entry: {
110214
+ type: "oauth",
110215
+ refresh: body.tokens.refresh_token,
110216
+ access: body.tokens.access_token,
110217
+ expires: expiresMs,
110218
+ ...body.tokens.account_id ? { accountId: body.tokens.account_id } : {}
110219
+ }
110220
+ });
110221
+ installedDataHome = xdgDataHome;
110222
+ log.info(`\xBB installed Codex auth at ${authPath}`);
110223
+ installedSubscriptionPaths.set(CODEX_AUTH_ENV, authPath);
110224
+ return {
110225
+ authPath,
110226
+ originalRefresh: body.tokens.refresh_token,
110227
+ originalIdToken: body.tokens.id_token
110228
+ };
110229
+ }
110230
+ function writeOpenCodeAuthEntry(params) {
110231
+ let existing = {};
110232
+ if (existsSync4(params.authPath)) {
110233
+ try {
110234
+ const parsed2 = JSON.parse(readFileSync2(params.authPath, "utf8"));
110235
+ if (parsed2 && typeof parsed2 === "object") existing = parsed2;
110236
+ } catch {
110237
+ log.warning(`\xBB ${params.authPath} was unreadable; rewriting it`);
110238
+ }
110239
+ }
110240
+ const merged = { ...existing, [params.provider]: params.entry };
110241
+ mkdirSync5(params.opencodeDir, { recursive: true });
110242
+ writeFileSync4(params.authPath, `${JSON.stringify(merged, null, 2)}
110243
+ `, { mode: 384 });
110244
+ }
110245
+ function installXaiAuth() {
110246
+ const raw2 = process.env[XAI_AUTH_ENV];
110247
+ if (!raw2) return null;
110248
+ const body = parseXaiAuthBody(raw2);
110249
+ if (!body) {
110250
+ log.warning(`\xBB ${XAI_AUTH_ENV} present but malformed; ignoring`);
110251
+ return null;
110252
+ }
110253
+ if (isXaiRejectedChain(body)) return null;
110254
+ const xdgDataHome = resolveDataHome();
110255
+ if (!xdgDataHome) return null;
110256
+ const opencodeDir = join5(xdgDataHome, "opencode");
110257
+ const authPath = join5(opencodeDir, "auth.json");
110258
+ writeOpenCodeAuthEntry({
110259
+ opencodeDir,
110260
+ authPath,
110261
+ provider: "xai",
110262
+ // opencode refreshes when `expires` is inside its skew window, and falls
110263
+ // back to the JWT's own exp when the stored value is absent. Decoding it
110264
+ // here means the first request uses the token we already hold instead of
110265
+ // burning a rotation on startup.
110266
+ entry: {
110267
+ type: "oauth",
110268
+ refresh: body.tokens.refresh_token,
110269
+ access: body.tokens.access_token,
110270
+ expires: decodeJwtExpMs(body.tokens.access_token) ?? 0
110271
+ }
110272
+ });
110273
+ installedDataHome = xdgDataHome;
110274
+ log.info(`\xBB installed Grok auth at ${authPath}`);
110275
+ installedSubscriptionPaths.set(XAI_AUTH_ENV, authPath);
110276
+ return { authPath, originalRefresh: body.tokens.refresh_token };
110277
+ }
110278
+ function isXaiRejectedChain(body) {
110279
+ if (!body.refresh_rejected_at) return false;
110280
+ log.warning(
110281
+ `\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.`
110282
+ );
110283
+ return true;
110284
+ }
110285
+ function installCodexHome() {
110286
+ const raw2 = process.env[CODEX_AUTH_ENV];
110287
+ if (!raw2) return null;
110288
+ const body = parseCodexAuthBody(raw2);
110289
+ if (!body) {
110290
+ log.warning(`\xBB ${CODEX_AUTH_ENV} present but malformed; ignoring`);
110291
+ return null;
110292
+ }
110293
+ if (isRejectedChain(body)) return null;
110294
+ if (!body.tokens.id_token) {
110295
+ log.warning(
110296
+ `\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.`
110297
+ );
110298
+ return null;
110299
+ }
110300
+ const root = resolveDataHome();
110301
+ if (!root) return null;
110302
+ const codexHome = join5(root, "codex");
110303
+ const authPath = join5(codexHome, "auth.json");
110304
+ mkdirSync5(codexHome, { recursive: true });
110305
+ writeFileSync4(authPath, stringifyCodexAuthBody(body), { mode: 384 });
110306
+ log.info(`\xBB installed Codex auth at ${authPath}`);
110307
+ return { codexHome, authPath, originalRefresh: body.tokens.refresh_token };
110308
+ }
110309
+ var dataHome;
110310
+ var unhostedSubscriptions = /* @__PURE__ */ new Set();
110311
+ function canInstallSubscription(name) {
110312
+ return name !== CODEX_AUTH_ENV && name !== XAI_AUTH_ENV || resolveDataHome() !== null;
110313
+ }
110314
+ function resolveDataHome() {
110315
+ if (process.env.CI !== "true") return join5(homedir2(), ".local", "share");
110316
+ if (dataHome === void 0) dataHome = bootstrapPullfrogDataDir();
110317
+ return dataHome;
110318
+ }
110319
+ function bootstrapPullfrogDataDir() {
110320
+ const user = userInfo().username;
110321
+ let primaryGroup;
110322
+ try {
110323
+ primaryGroup = execFileSync3("id", ["-gn", user], { stdio: "pipe", encoding: "utf-8" }).trim();
110324
+ } catch {
110325
+ primaryGroup = user;
110326
+ }
110327
+ try {
110328
+ execFileSync3("sudo", ["-n", "mkdir", "-p", PULLFROG_DATA_DIR], { stdio: "pipe" });
110329
+ execFileSync3("sudo", ["-n", "chown", `${user}:${primaryGroup}`, PULLFROG_DATA_DIR], {
110330
+ stdio: "pipe"
110331
+ });
110332
+ execFileSync3("sudo", ["-n", "chmod", "700", PULLFROG_DATA_DIR], { stdio: "pipe" });
110333
+ } catch (err) {
110334
+ log.warning(
110335
+ `\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.`
110336
+ );
110337
+ for (const name of [CODEX_AUTH_ENV, XAI_AUTH_ENV]) {
110338
+ if (process.env[name]) unhostedSubscriptions.add(name);
110339
+ delete process.env[name];
110340
+ }
110341
+ return null;
110342
+ }
110343
+ const runDir = mkdtempSync(join5(PULLFROG_DATA_DIR, "run-"));
110344
+ if (process.env.RUNNER_ENVIRONMENT !== "github-hosted") {
110345
+ saveSecretState(SUBSCRIPTION_DATA_DIR_STATE, runDir);
110346
+ }
110347
+ return runDir;
110348
+ }
110349
+
110294
110350
  // utils/secrets.ts
110295
110351
  var SENSITIVE_PATTERNS = [
110296
110352
  /_KEY$/i,
@@ -110937,9 +110993,8 @@ var codex = agent({
110937
110993
  initialUsage: initial.usage,
110938
110994
  reflectionPrompt: buildReflectionPrompt(ctx.toolState),
110939
110995
  // a resume needs the thread codex recorded. without one there is nothing
110940
- // to continue, and `resume --last` would be worse than not resuming: it
110941
- // picks the newest recorded session in this cwd, which on a self-hosted
110942
- // runner with a shared CODEX_HOME is somebody else's run.
110996
+ // to continue, and `resume --last` could pick another run's session off CI,
110997
+ // where CODEX_HOME is the shared ~/.local/share/codex (in CI it is per run).
110943
110998
  canResume: (r) => r.threadId !== void 0,
110944
110999
  resume: async (c) => runCodex({
110945
111000
  ...runnerArgs,
@@ -110966,7 +111021,7 @@ var codex = agent({
110966
111021
  import { spawn as nodeSpawn2 } from "node:child_process";
110967
111022
  import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync6 } from "node:fs";
110968
111023
  import { totalmem } from "node:os";
110969
- import { join as join7 } from "node:path";
111024
+ import { join as join8 } from "node:path";
110970
111025
  import { performance as performance7 } from "node:perf_hooks";
110971
111026
 
110972
111027
  // node_modules/.pnpm/@opencode-ai+sdk@1.18.29/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js
@@ -116375,200 +116430,27 @@ var import_cross_spawn = __toESM(require_cross_spawn(), 1);
116375
116430
  // agents/opencode.ts
116376
116431
  var import_undici = __toESM(require_undici2(), 1);
116377
116432
 
116378
- // utils/worktree.ts
116379
- async function dirtyTrackedPaths(params = {}) {
116380
- const result = await spawn({
116381
- cmd: "git",
116382
- args: ["diff", "--name-only", "HEAD"],
116383
- env: process.env,
116384
- cwd: params.cwd ?? process.cwd(),
116385
- activityTimeout: 0
116386
- });
116387
- if (result.exitCode !== 0) {
116388
- throw new Error(
116389
- `git diff --name-only HEAD failed (exit ${result.exitCode}): ${result.stderr.trim() || "(no stderr)"}`
116390
- );
116391
- }
116392
- return new Set(result.stdout.split("\n").filter(Boolean));
116393
- }
116394
- async function restoreDirtiedSince(params) {
116395
- const dirtied = [...await dirtyTrackedPaths({ cwd: params.cwd ?? process.cwd() })].filter(
116396
- (path4) => !params.before.has(path4)
116397
- );
116398
- if (dirtied.length === 0) return;
116399
- const result = await spawn({
116400
- cmd: "git",
116401
- args: ["restore", "--staged", "--worktree", "--", ...dirtied],
116402
- env: process.env,
116403
- cwd: params.cwd ?? process.cwd(),
116404
- activityTimeout: 0
116405
- });
116406
- if (result.exitCode !== 0) {
116407
- log.warning(
116408
- `\xBB failed to restore ${dirtied.length} tracked file(s) modified by ${params.actor}: ${result.stderr.trim() || "(no stderr)"}`
116409
- );
116410
- return;
116411
- }
116412
- log.info(
116413
- `\xBB restored ${dirtied.length} tracked file(s) modified by ${params.actor}: ${dirtied.join(", ")}`
116414
- );
116415
- }
116416
-
116417
- // agents/opencodePlugin.ts
116418
- var PULLFROG_BUS_EVENT_TYPE = "pullfrog_bus_event";
116419
- var PULLFROG_OPENCODE_GATE_PLUGIN_FILENAME = "pullfrog-subagent-gate.ts";
116420
- var PULLFROG_OPENCODE_PLUGIN_SOURCE = `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
116421
- // surfaces opencode subagent activity that the CLI's run-loop discards. see
116422
- // action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
116423
- // inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
116424
- // the user's working tree. the subagent gate ships as a separate plugin
116425
- // (pullfrog-subagent-gate.ts).
116426
-
116427
- const PULLFROG_BUS_EVENT_TYPE = ${JSON.stringify(PULLFROG_BUS_EVENT_TYPE)};
116428
-
116429
- // orchestratorSessionID locks to the first sessionID we observe on the bus
116430
- // (\`message.part.updated\`). opencode's run command creates exactly one
116431
- // top-level session before any subagent is dispatched, and the orchestrator
116432
- // emits at least one event before any subagent does \u2014 so first-seen-wins
116433
- // captures the orchestrator; every subagent part has a non-matching
116434
- // sessionID.
116435
- let orchestratorSessionID: string | undefined;
116436
-
116437
- function isOrchestratorTaskDispatch(part: {
116438
- type?: string;
116439
- tool?: string;
116440
- state?: { status?: string };
116441
- } | undefined): boolean {
116442
- if (!part || part.type !== "tool") return false;
116443
- if (part.tool !== "task") return false;
116444
- // only forward at status="running" (not "pending"). at pending the
116445
- // state.input is still {} \u2014 the orchestrator has emitted the part shell
116446
- // but the LLM hasn't filled in description/subagent_type/prompt yet. by
116447
- // running, input is populated and recordTaskDispatch can derive the lens
116448
- // label correctly.
116449
- return part.state?.status === "running";
116450
- }
116451
-
116452
- export default async function pullfrogEventsPlugin() {
116453
- return {
116454
- event: async (input: {
116455
- event: {
116456
- type: string;
116457
- properties?: {
116458
- part?: {
116459
- sessionID?: string;
116460
- type?: string;
116461
- tool?: string;
116462
- state?: { status?: string };
116463
- };
116464
- };
116465
- };
116466
- }) => {
116467
- const event = input?.event;
116468
- if (!event || typeof event !== "object") return;
116469
- if (event.type !== "message.part.updated") return;
116470
- const part = event.properties?.part;
116471
- const sessionID = part?.sessionID;
116472
- if (typeof sessionID !== "string" || sessionID.length === 0) return;
116473
- if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
116474
-
116475
- if (sessionID === orchestratorSessionID) {
116476
- // skip orchestrator events EXCEPT early task dispatches.
116477
- if (!isOrchestratorTaskDispatch(part)) return;
116478
- }
116479
-
116480
- try {
116481
- const line = JSON.stringify({
116482
- type: PULLFROG_BUS_EVENT_TYPE,
116483
- bus_event: event,
116484
- });
116485
- process.stdout.write(line + "\\n");
116486
- } catch {
116487
- // a circular reference or BigInt etc. would throw; swallow rather
116488
- // than letting a single bad event take down the plugin.
116489
- }
116490
- },
116491
- };
116492
- }
116493
- `;
116494
- function buildOpencodeSubagentGateSource(deniedTools) {
116495
- return `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
116496
- // hard-blocks state-mutating MCP tool calls from opencode subagents (PR
116497
- // following pullfrog/app#796 \u2014 see action/agents/subagentToolGates.ts). see
116498
- // action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
116499
- // inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
116500
- // the user's working tree.
116501
-
116502
- // embedded at template-render time so the plugin source has no imports. the
116503
- // denied set is derived from each tool's \`mutates\` flag \u2014 see
116504
- // action/agents/subagentToolGates.ts.
116505
- const SUBAGENT_DENIED_TOOLS = new Set(${JSON.stringify(deniedTools)});
116506
-
116507
- // opencode presents MCP tools as \`pullfrog_<bare>\`; strip that to compare
116508
- // against the canonical bare names in SUBAGENT_DENIED_TOOLS. native tools
116509
- // (read, write, bash, task, etc.) lack the prefix and never match the deny
116510
- // list anyway.
116511
- function stripPullfrogPrefix(toolName: string): string {
116512
- if (toolName.startsWith("pullfrog_")) return toolName.slice("pullfrog_".length);
116513
- return toolName;
116514
- }
116515
-
116516
- // orchestratorSessionID locks to the first sessionID we observe on
116517
- // \`tool.execute.before\`. the orchestrator must invoke a tool (e.g. its
116518
- // \`task\` dispatch) before any subagent session exists, so first-seen-wins
116519
- // always captures the orchestrator; every subagent call therefore has a
116520
- // non-matching sessionID and is subject to the deny check.
116521
- let orchestratorSessionID: string | undefined;
116522
-
116523
- export default async function pullfrogSubagentGatePlugin() {
116524
- return {
116525
- "tool.execute.before": async (
116526
- input: { tool?: string; sessionID?: string; callID?: string },
116527
- _output: { args?: Record<string, unknown> }
116528
- ) => {
116529
- // input: { tool, sessionID, callID } \u2014 confirmed against
116530
- // anomalyco/opencode packages/opencode/src/session/tools.ts which
116531
- // calls plugin.trigger("tool.execute.before", { tool, sessionID,
116532
- // callID }, { args }). throwing here surfaces as a tool error to the
116533
- // model (Effect's run.promise propagates the rejection through the
116534
- // AI SDK's tool-runtime), so the subagent sees a denial and can
116535
- // pick a different action.
116536
- const sessionID = typeof input?.sessionID === "string" ? input.sessionID : "";
116537
- const tool = typeof input?.tool === "string" ? input.tool : "";
116538
- // above the sessionID check: the bash block must never depend on a
116539
- // session id being present.
116540
- if (tool === "bash") {
116541
- throw new Error("the native bash tool is disabled in Pullfrog runs.");
116542
- }
116543
- if (!sessionID || !tool) return;
116544
- if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
116545
- if (sessionID === orchestratorSessionID) return;
116546
- const bare = stripPullfrogPrefix(tool);
116547
- if (!SUBAGENT_DENIED_TOOLS.has(bare)) return;
116548
- throw new Error(
116549
- "subagent attempted to call denied tool '" + bare + "'. " +
116550
- "subagents share the orchestrator's in-process working tree and toolState; " +
116551
- "state-changing MCP tools (checkout_pr, push_branch, create_pull_request_review, " +
116552
- "report_progress, etc.) are reserved for the orchestrator. " +
116553
- "report findings back to the orchestrator and let it perform the mutation."
116554
- );
116555
- },
116556
- };
116557
- }
116558
- `;
116559
- }
116560
-
116561
116433
  // utils/openCodeModels.ts
116562
116434
  import { spawnSync as spawnSync3 } from "node:child_process";
116435
+ import { join as join7 } from "node:path";
116563
116436
  var baseline;
116564
116437
  var authorized;
116565
116438
  var failure;
116566
116439
  var ANSI_PATTERN = /\[[0-9;]*m/g;
116567
- function readModels(cliPath) {
116568
- const result = spawnSync3(cliPath, ["models"], {
116440
+ function openCodeHomeEnv(tmpdir3) {
116441
+ return {
116442
+ HOME: tmpdir3,
116443
+ XDG_CONFIG_HOME: join7(tmpdir3, ".config"),
116444
+ XDG_DATA_HOME: getSubscriptionDataHome() ?? join7(tmpdir3, ".local", "share"),
116445
+ XDG_CACHE_HOME: join7(tmpdir3, ".cache"),
116446
+ XDG_STATE_HOME: join7(tmpdir3, ".local", "state")
116447
+ };
116448
+ }
116449
+ function readModels(params) {
116450
+ const result = spawnSync3(params.cliPath, ["models"], {
116569
116451
  encoding: "utf-8",
116570
116452
  timeout: 3e4,
116571
- env: process.env,
116453
+ env: { ...process.env, ...openCodeHomeEnv(params.tmpdir) },
116572
116454
  stdio: ["ignore", "pipe", "pipe"]
116573
116455
  });
116574
116456
  if (result.status !== 0) {
@@ -116585,12 +116467,12 @@ function readModels(cliPath) {
116585
116467
  function getModelsFailure() {
116586
116468
  return failure;
116587
116469
  }
116588
- function captureBaselineModels(cliPath) {
116589
- baseline = readModels(cliPath);
116470
+ function captureBaselineModels(params) {
116471
+ baseline = readModels(params);
116590
116472
  log.debug(`\xBB opencode baseline: ${baseline.size} models`);
116591
116473
  }
116592
- function captureAuthorizedModels(cliPath) {
116593
- authorized = readModels(cliPath);
116474
+ function captureAuthorizedModels(params) {
116475
+ authorized = readModels(params);
116594
116476
  const base = baseline;
116595
116477
  if (base) {
116596
116478
  const diff = [...authorized].filter((m) => !base.has(m));
@@ -116610,6 +116492,190 @@ function getAuthorizedModels() {
116610
116492
  return authorized;
116611
116493
  }
116612
116494
 
116495
+ // utils/worktree.ts
116496
+ async function dirtyTrackedPaths(params = {}) {
116497
+ const result = await spawn({
116498
+ cmd: "git",
116499
+ args: ["diff", "--name-only", "HEAD"],
116500
+ env: process.env,
116501
+ cwd: params.cwd ?? process.cwd(),
116502
+ activityTimeout: 0
116503
+ });
116504
+ if (result.exitCode !== 0) {
116505
+ throw new Error(
116506
+ `git diff --name-only HEAD failed (exit ${result.exitCode}): ${result.stderr.trim() || "(no stderr)"}`
116507
+ );
116508
+ }
116509
+ return new Set(result.stdout.split("\n").filter(Boolean));
116510
+ }
116511
+ async function restoreDirtiedSince(params) {
116512
+ const dirtied = [...await dirtyTrackedPaths({ cwd: params.cwd ?? process.cwd() })].filter(
116513
+ (path4) => !params.before.has(path4)
116514
+ );
116515
+ if (dirtied.length === 0) return;
116516
+ const result = await spawn({
116517
+ cmd: "git",
116518
+ args: ["restore", "--staged", "--worktree", "--", ...dirtied],
116519
+ env: process.env,
116520
+ cwd: params.cwd ?? process.cwd(),
116521
+ activityTimeout: 0
116522
+ });
116523
+ if (result.exitCode !== 0) {
116524
+ log.warning(
116525
+ `\xBB failed to restore ${dirtied.length} tracked file(s) modified by ${params.actor}: ${result.stderr.trim() || "(no stderr)"}`
116526
+ );
116527
+ return;
116528
+ }
116529
+ log.info(
116530
+ `\xBB restored ${dirtied.length} tracked file(s) modified by ${params.actor}: ${dirtied.join(", ")}`
116531
+ );
116532
+ }
116533
+
116534
+ // agents/opencodePlugin.ts
116535
+ var PULLFROG_BUS_EVENT_TYPE = "pullfrog_bus_event";
116536
+ var PULLFROG_OPENCODE_GATE_PLUGIN_FILENAME = "pullfrog-subagent-gate.ts";
116537
+ var PULLFROG_OPENCODE_PLUGIN_SOURCE = `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
116538
+ // surfaces opencode subagent activity that the CLI's run-loop discards. see
116539
+ // action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
116540
+ // inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
116541
+ // the user's working tree. the subagent gate ships as a separate plugin
116542
+ // (pullfrog-subagent-gate.ts).
116543
+
116544
+ const PULLFROG_BUS_EVENT_TYPE = ${JSON.stringify(PULLFROG_BUS_EVENT_TYPE)};
116545
+
116546
+ // orchestratorSessionID locks to the first sessionID we observe on the bus
116547
+ // (\`message.part.updated\`). opencode's run command creates exactly one
116548
+ // top-level session before any subagent is dispatched, and the orchestrator
116549
+ // emits at least one event before any subagent does \u2014 so first-seen-wins
116550
+ // captures the orchestrator; every subagent part has a non-matching
116551
+ // sessionID.
116552
+ let orchestratorSessionID: string | undefined;
116553
+
116554
+ function isOrchestratorTaskDispatch(part: {
116555
+ type?: string;
116556
+ tool?: string;
116557
+ state?: { status?: string };
116558
+ } | undefined): boolean {
116559
+ if (!part || part.type !== "tool") return false;
116560
+ if (part.tool !== "task") return false;
116561
+ // only forward at status="running" (not "pending"). at pending the
116562
+ // state.input is still {} \u2014 the orchestrator has emitted the part shell
116563
+ // but the LLM hasn't filled in description/subagent_type/prompt yet. by
116564
+ // running, input is populated and recordTaskDispatch can derive the lens
116565
+ // label correctly.
116566
+ return part.state?.status === "running";
116567
+ }
116568
+
116569
+ export default async function pullfrogEventsPlugin() {
116570
+ return {
116571
+ event: async (input: {
116572
+ event: {
116573
+ type: string;
116574
+ properties?: {
116575
+ part?: {
116576
+ sessionID?: string;
116577
+ type?: string;
116578
+ tool?: string;
116579
+ state?: { status?: string };
116580
+ };
116581
+ };
116582
+ };
116583
+ }) => {
116584
+ const event = input?.event;
116585
+ if (!event || typeof event !== "object") return;
116586
+ if (event.type !== "message.part.updated") return;
116587
+ const part = event.properties?.part;
116588
+ const sessionID = part?.sessionID;
116589
+ if (typeof sessionID !== "string" || sessionID.length === 0) return;
116590
+ if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
116591
+
116592
+ if (sessionID === orchestratorSessionID) {
116593
+ // skip orchestrator events EXCEPT early task dispatches.
116594
+ if (!isOrchestratorTaskDispatch(part)) return;
116595
+ }
116596
+
116597
+ try {
116598
+ const line = JSON.stringify({
116599
+ type: PULLFROG_BUS_EVENT_TYPE,
116600
+ bus_event: event,
116601
+ });
116602
+ process.stdout.write(line + "\\n");
116603
+ } catch {
116604
+ // a circular reference or BigInt etc. would throw; swallow rather
116605
+ // than letting a single bad event take down the plugin.
116606
+ }
116607
+ },
116608
+ };
116609
+ }
116610
+ `;
116611
+ function buildOpencodeSubagentGateSource(ctx) {
116612
+ 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.`;
116613
+ return `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
116614
+ // hard-blocks state-mutating MCP tool calls from opencode subagents (PR
116615
+ // following pullfrog/app#796 \u2014 see action/agents/subagentToolGates.ts). see
116616
+ // action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
116617
+ // inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
116618
+ // the user's working tree.
116619
+
116620
+ // embedded at template-render time so the plugin source has no imports. the
116621
+ // denied set is derived from each tool's \`mutates\` flag \u2014 see
116622
+ // action/agents/subagentToolGates.ts.
116623
+ const SUBAGENT_DENIED_TOOLS = new Set(${JSON.stringify(ctx.subagentDeniedTools)});
116624
+
116625
+ // opencode presents MCP tools as \`pullfrog_<bare>\`; strip that to compare
116626
+ // against the canonical bare names in SUBAGENT_DENIED_TOOLS. native tools
116627
+ // (read, write, bash, task, etc.) lack the prefix and never match the deny
116628
+ // list anyway.
116629
+ function stripPullfrogPrefix(toolName: string): string {
116630
+ if (toolName.startsWith("pullfrog_")) return toolName.slice("pullfrog_".length);
116631
+ return toolName;
116632
+ }
116633
+
116634
+ // orchestratorSessionID locks to the first sessionID we observe on
116635
+ // \`tool.execute.before\`. the orchestrator must invoke a tool (e.g. its
116636
+ // \`task\` dispatch) before any subagent session exists, so first-seen-wins
116637
+ // always captures the orchestrator; every subagent call therefore has a
116638
+ // non-matching sessionID and is subject to the deny check.
116639
+ let orchestratorSessionID: string | undefined;
116640
+
116641
+ export default async function pullfrogSubagentGatePlugin() {
116642
+ return {
116643
+ "tool.execute.before": async (
116644
+ input: { tool?: string; sessionID?: string; callID?: string },
116645
+ _output: { args?: Record<string, unknown> }
116646
+ ) => {
116647
+ // input: { tool, sessionID, callID } \u2014 confirmed against
116648
+ // anomalyco/opencode packages/opencode/src/session/tools.ts which
116649
+ // calls plugin.trigger("tool.execute.before", { tool, sessionID,
116650
+ // callID }, { args }). throwing here surfaces as a tool error to the
116651
+ // model (Effect's run.promise propagates the rejection through the
116652
+ // AI SDK's tool-runtime), so the subagent sees a denial and can
116653
+ // pick a different action.
116654
+ const sessionID = typeof input?.sessionID === "string" ? input.sessionID : "";
116655
+ const tool = typeof input?.tool === "string" ? input.tool : "";
116656
+ // above the sessionID check: the bash block must never depend on a
116657
+ // session id being present.
116658
+ if (tool === "bash") {
116659
+ throw new Error(${JSON.stringify(bashRefusal)});
116660
+ }
116661
+ if (!sessionID || !tool) return;
116662
+ if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
116663
+ if (sessionID === orchestratorSessionID) return;
116664
+ const bare = stripPullfrogPrefix(tool);
116665
+ if (!SUBAGENT_DENIED_TOOLS.has(bare)) return;
116666
+ throw new Error(
116667
+ "subagent attempted to call denied tool '" + bare + "'. " +
116668
+ "subagents share the orchestrator's in-process working tree and toolState; " +
116669
+ "state-changing MCP tools (checkout_pr, push_branch, create_pull_request_review, " +
116670
+ "report_progress, etc.) are reserved for the orchestrator. " +
116671
+ "report findings back to the orchestrator and let it perform the mutation."
116672
+ );
116673
+ },
116674
+ };
116675
+ }
116676
+ `;
116677
+ }
116678
+
116613
116679
  // agents/subagentModels.ts
116614
116680
  function deriveSubagentModels(orchestratorSpec) {
116615
116681
  if (!orchestratorSpec) return { reviewer: void 0 };
@@ -116984,6 +117050,7 @@ async function dispatchEvent(ctx, event) {
116984
117050
  const err = event.properties.error;
116985
117051
  const message = err ? extractErrorMessage(err) : "(no error payload)";
116986
117052
  log.info(`\xBB ${ctx.label} session error: ${message}`);
117053
+ if (err) ctx.lastSessionError = message;
116987
117054
  return;
116988
117055
  }
116989
117056
  }
@@ -117122,6 +117189,7 @@ async function runPromptTurn(ctx, params) {
117122
117189
  ctx.promptMessageID = void 0;
117123
117190
  ctx.diagnostic.sawModelOutput = false;
117124
117191
  ctx.diagnostic.idleSec = void 0;
117192
+ ctx.lastSessionError = void 0;
117125
117193
  let assistant;
117126
117194
  let returnedParts;
117127
117195
  let networkError = null;
@@ -117166,11 +117234,12 @@ async function runPromptTurn(ctx, params) {
117166
117234
  }
117167
117235
  const cause = ctx.serverExitCause() ?? ctx.diagnostic.lastProviderError;
117168
117236
  const diagnosis = cause ? ` \u2014 likely cause: ${cause}` : "";
117237
+ const promptCause = ctx.serverExitCause() ?? (ctx.diagnostic.sawModelOutput ? void 0 : ctx.lastSessionError) ?? ctx.diagnostic.lastProviderError;
117169
117238
  if (networkError) {
117170
117239
  return {
117171
117240
  success: false,
117172
117241
  output: finalText,
117173
- 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}`,
117242
+ 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}` : ""}`,
117174
117243
  usage
117175
117244
  };
117176
117245
  }
@@ -117362,15 +117431,14 @@ var opencode = agent({
117362
117431
  const isBedrockRoute = rawModel !== void 0 && bedrockModelId !== void 0 && bedrockModelId === rawModel;
117363
117432
  const vertexModel = resolveVertexOpenCodeModel(rawModel);
117364
117433
  const model = vertexModel ?? (isBedrockRoute ? `amazon-bedrock/${rawModel}` : rawModel);
117365
- const homeEnv = {
117366
- HOME: ctx.tmpdir,
117367
- XDG_CONFIG_HOME: join7(ctx.tmpdir, ".config")
117368
- };
117369
- const opencodePluginDir = join7(homeEnv.XDG_CONFIG_HOME, "opencode", "plugin");
117434
+ const codexAuth = installCodexAuth();
117435
+ const xaiAuth = installXaiAuth();
117436
+ const homeEnv = openCodeHomeEnv(ctx.tmpdir);
117437
+ const opencodePluginDir = join8(homeEnv.XDG_CONFIG_HOME, "opencode", "plugin");
117370
117438
  mkdirSync7(opencodePluginDir, { recursive: true });
117371
117439
  writeFileSync6(
117372
- join7(opencodePluginDir, PULLFROG_OPENCODE_GATE_PLUGIN_FILENAME),
117373
- buildOpencodeSubagentGateSource(ctx.subagentDeniedTools)
117440
+ join8(opencodePluginDir, PULLFROG_OPENCODE_GATE_PLUGIN_FILENAME),
117441
+ buildOpencodeSubagentGateSource(ctx)
117374
117442
  );
117375
117443
  const agentBrowserVersion = getDevDependencyVersion("agent-browser");
117376
117444
  addSkill({
@@ -117380,8 +117448,6 @@ var opencode = agent({
117380
117448
  agent: "opencode"
117381
117449
  });
117382
117450
  installBundledSkills({ home: homeEnv.HOME });
117383
- const codexAuth = installCodexAuth();
117384
- const xaiAuth = installXaiAuth();
117385
117451
  const permissionOverride = JSON.stringify({
117386
117452
  external_directory: { "*": "deny", "/tmp/*": "allow" },
117387
117453
  read: { "*": "allow", ...GIT_NATIVE_READ_DENY_OPENCODE },
@@ -117400,7 +117466,6 @@ var opencode = agent({
117400
117466
  delete env2.OPENCODE_EXPERIMENTAL_CODE_MODE;
117401
117467
  const writebacks = [];
117402
117468
  if (codexAuth) {
117403
- env2.XDG_DATA_HOME = codexAuth.xdgDataHome;
117404
117469
  delete env2.OPENAI_API_KEY;
117405
117470
  writebacks.push({
117406
117471
  secretName: "CODEX_AUTH_JSON",
@@ -117411,7 +117476,6 @@ var opencode = agent({
117411
117476
  });
117412
117477
  }
117413
117478
  if (xaiAuth) {
117414
- env2.XDG_DATA_HOME = xaiAuth.xdgDataHome;
117415
117479
  delete env2.XAI_API_KEY;
117416
117480
  writebacks.push({
117417
117481
  secretName: "GROK_AUTH_JSON",
@@ -117484,6 +117548,7 @@ var opencode = agent({
117484
117548
  mcpToolCalls: 0,
117485
117549
  recentStderr: server.recentStderr,
117486
117550
  serverExitCause: server.exitCause,
117551
+ lastSessionError: void 0,
117487
117552
  diagnostic: {
117488
117553
  label: "Pullfrog",
117489
117554
  recentStderr: server.recentStderr,
@@ -172402,7 +172467,7 @@ function closeBrowserDaemon(toolState) {
172402
172467
  // mcp/checkout.ts
172403
172468
  import { createHash as createHash2 } from "node:crypto";
172404
172469
  import { statSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync10 } from "node:fs";
172405
- import { join as join15 } from "node:path";
172470
+ import { join as join16 } from "node:path";
172406
172471
 
172407
172472
  // node_modules/.pnpm/@ark+schema@0.56.0/node_modules/@ark/schema/out/shared/errors.js
172408
172473
  var ArkError = class _ArkError extends CastableBase {
@@ -180773,13 +180838,14 @@ function ensureRepoState(toolState, init) {
180773
180838
  // utils/changeImpact.ts
180774
180839
  import { spawn as spawn2 } from "node:child_process";
180775
180840
  import { writeFileSync as writeFileSync7 } from "node:fs";
180776
- import { join as join8 } from "node:path";
180841
+ import { join as join9 } from "node:path";
180777
180842
  var MAX_CANDIDATES = 24;
180778
180843
  var MAX_ATOM_LENGTH = 128;
180779
180844
  var MAX_ATOMS = 12;
180780
180845
  var MAX_REFERENCES = 6;
180781
180846
  var MAX_EXCERPT = 160;
180782
180847
  var MAX_BYTES = 16e3;
180848
+ var LOOKUP_BUDGET_MS = 6e4;
180783
180849
  function isSymbolShaped(atom) {
180784
180850
  return atom.length >= 4 && atom.length <= MAX_ATOM_LENGTH && /[A-Z_]/.test(atom);
180785
180851
  }
@@ -180982,7 +181048,7 @@ function consumeGrepChunk(params) {
180982
181048
  async function lookupReferences(params) {
180983
181049
  const buckets = emptyBuckets(params.candidates);
180984
181050
  if (params.candidates.length === 0) return { buckets, error: void 0 };
180985
- const patterns = params.candidates.flatMap((candidate) => ["-e", candidate.atom]);
181051
+ const pattern = params.candidates.map((candidate) => candidate.atom).join("|");
180986
181052
  const child = spawn2(
180987
181053
  "git",
180988
181054
  [
@@ -180993,13 +181059,14 @@ async function lookupReferences(params) {
180993
181059
  "-z",
180994
181060
  "-I",
180995
181061
  "-w",
180996
- "-F",
180997
- ...patterns,
181062
+ "-E",
181063
+ "-e",
181064
+ pattern,
180998
181065
  params.treeish,
180999
181066
  "--",
181000
181067
  ":(top)"
181001
181068
  ],
181002
- { env: resolveEnv(void 0), stdio: ["ignore", "pipe", "pipe"] }
181069
+ { env: resolveEnv(void 0), stdio: ["ignore", "pipe", "pipe"], signal: params.signal }
181003
181070
  );
181004
181071
  const state = createGrepRecordState();
181005
181072
  const maxAtomLength = Math.max(...params.candidates.map((candidate) => candidate.atom.length));
@@ -181019,7 +181086,7 @@ async function lookupReferences(params) {
181019
181086
  child.stderr.resume();
181020
181087
  const status = await new Promise((resolve3) => {
181021
181088
  child.once("error", (error49) => {
181022
- spawnError = error49.message;
181089
+ spawnError = error49.cause instanceof Error ? error49.cause.message : error49.message;
181023
181090
  resolve3(null);
181024
181091
  });
181025
181092
  child.once("close", resolve3);
@@ -181107,8 +181174,10 @@ async function createChangeImpactArtifact(ctx, params) {
181107
181174
  const lookup2 = await lookupReferences({
181108
181175
  candidates: candidates.selected,
181109
181176
  addedLines: candidates.addedLines,
181110
- treeish: params.headSha
181177
+ treeish: params.headSha,
181178
+ signal: AbortSignal.any([params.signal, AbortSignal.timeout(LOOKUP_BUDGET_MS)])
181111
181179
  });
181180
+ params.signal.throwIfAborted();
181112
181181
  const artifact = formatArtifact({
181113
181182
  entries: candidates.selected.map((change) => {
181114
181183
  return { change, bucket: lookup2.buckets.get(change.atom) ?? { count: 0, samples: [] } };
@@ -181120,7 +181189,7 @@ async function createChangeImpactArtifact(ctx, params) {
181120
181189
  headSha: params.headSha,
181121
181190
  lookupError: lookup2.error
181122
181191
  });
181123
- const path4 = join8(ctx.tmpdir, `pr-${params.pullNumber}-${params.headSha.slice(0, 7)}-impact.md`);
181192
+ const path4 = join9(ctx.tmpdir, `pr-${params.pullNumber}-${params.headSha.slice(0, 7)}-impact.md`);
181124
181193
  writeFileSync7(path4, artifact.content);
181125
181194
  return {
181126
181195
  path: path4,
@@ -181374,7 +181443,7 @@ function readNumber(params) {
181374
181443
  import { execSync } from "node:child_process";
181375
181444
  import { createHash } from "node:crypto";
181376
181445
  import { readFileSync as readFileSync4, realpathSync, unlinkSync } from "node:fs";
181377
- import { join as join9 } from "node:path";
181446
+ import { join as join10 } from "node:path";
181378
181447
 
181379
181448
  // utils/shell.ts
181380
181449
  import { spawnSync as spawnSync5 } from "node:child_process";
@@ -181464,7 +181533,7 @@ function resolveHooksDir(cwd, gitPath) {
181464
181533
  cwd,
181465
181534
  log: false
181466
181535
  }).trim();
181467
- const hooksDir = join9(commonDir, "hooks");
181536
+ const hooksDir = join10(commonDir, "hooks");
181468
181537
  hooksDirCache.set(cwd, hooksDir);
181469
181538
  return hooksDir;
181470
181539
  }
@@ -181511,7 +181580,8 @@ async function $git(subcommand, args2, options) {
181511
181580
  GIT_CONFIG_COUNT: "0",
181512
181581
  GIT_CONFIG_PARAMETERS: ""
181513
181582
  },
181514
- activityTimeout: 0
181583
+ activityTimeout: 0,
181584
+ signal: options.signal
181515
181585
  });
181516
181586
  if (result.stderr.includes("askpass-compromised")) {
181517
181587
  log.info("askpass code was replayed after revoke \u2014 token has been revoked");
@@ -181578,9 +181648,10 @@ import { spawnSync as spawnSync7 } from "node:child_process";
181578
181648
  // mcp/shell.ts
181579
181649
  import { spawn as spawn3, spawnSync as spawnSync6 } from "node:child_process";
181580
181650
  import { randomUUID as randomUUID3 } from "node:crypto";
181581
- import { closeSync, openSync, writeFileSync as writeFileSync8 } from "node:fs";
181651
+ import { once } from "node:events";
181652
+ import { closeSync, existsSync as existsSync7, openSync, writeFileSync as writeFileSync8 } from "node:fs";
181582
181653
  import { userInfo as userInfo2 } from "node:os";
181583
- import { join as join10 } from "node:path";
181654
+ import { join as join11 } from "node:path";
181584
181655
  import { setTimeout as sleep3 } from "node:timers/promises";
181585
181656
 
181586
181657
  // node_modules/.pnpm/@toon-format+toon@1.4.0/node_modules/@toon-format/toon/dist/index.mjs
@@ -182224,7 +182295,7 @@ function buildFsMounts(repoDir) {
182224
182295
  function shimMount() {
182225
182296
  const tempDir = process.env.PULLFROG_TEMP_DIR;
182226
182297
  if (!tempDir) return "";
182227
- const dir = join10(tempDir, "pm-bin").replace(/'/g, "'\\''");
182298
+ const dir = join11(tempDir, "pm-bin").replace(/'/g, "'\\''");
182228
182299
  return `[ -d '${dir}' ] && mount --bind '${dir}' '${dir}' 2>/dev/null && mount -o remount,bind,ro '${dir}' 2>/dev/null;`;
182229
182300
  }
182230
182301
  var _repoRoot;
@@ -182395,7 +182466,7 @@ ${spawnError}` : spawnError;
182395
182466
  }
182396
182467
  function capOutput(output) {
182397
182468
  if (output.length <= MAX_OUTPUT_CHARS) return output;
182398
- const fullPath = join10(getTempDir(), `shell-${randomUUID3().slice(0, 8)}.log`);
182469
+ const fullPath = join11(getTempDir(), `shell-${randomUUID3().slice(0, 8)}.log`);
182399
182470
  writeFileSync8(fullPath, output);
182400
182471
  const elided = output.length - MAX_OUTPUT_CHARS;
182401
182472
  return `... [${elided} chars truncated; output saved to ${fullPath}] ...
@@ -182432,6 +182503,7 @@ Do NOT use this tool for git commands \u2014 use the dedicated git tools instead
182432
182503
  }
182433
182504
  const timeout = Math.min(params.timeout ?? 3e4, 12e4);
182434
182505
  const cwd = params.working_directory ?? process.cwd();
182506
+ if (!existsSync7(cwd)) throw new Error(`working_directory does not exist: ${cwd}`);
182435
182507
  const env2 = resolveEnv(ctx.payload.shell === "enabled" ? "inherit" : "restricted");
182436
182508
  if (params.command.includes("agent-browser")) {
182437
182509
  const daemonError = ensureBrowserDaemon(ctx.toolState);
@@ -182450,8 +182522,8 @@ Do NOT use this tool for git commands \u2014 use the dedicated git tools instead
182450
182522
  if (params.background) {
182451
182523
  const tempDir = getTempDir();
182452
182524
  const handle = `bg-${randomUUID3().slice(0, 8)}`;
182453
- const outputPath = join10(tempDir, `${handle}.log`);
182454
- const pidPath = join10(tempDir, `${handle}.pid`);
182525
+ const outputPath = join11(tempDir, `${handle}.log`);
182526
+ const pidPath = join11(tempDir, `${handle}.pid`);
182455
182527
  const logFd = openSync(outputPath, "a");
182456
182528
  let proc;
182457
182529
  try {
@@ -182465,7 +182537,8 @@ Do NOT use this tool for git commands \u2014 use the dedicated git tools instead
182465
182537
  closeSync(logFd);
182466
182538
  }
182467
182539
  if (!proc.pid) {
182468
- throw new Error("failed to start background process");
182540
+ const [error49] = await once(proc, "error");
182541
+ throw new Error(`failed to start background process: ${error49.message}`);
182469
182542
  }
182470
182543
  proc.unref();
182471
182544
  writeFileSync8(pidPath, `${proc.pid}
@@ -182742,12 +182815,12 @@ function postProcessRangeDiff(raw2, contextLines = 3) {
182742
182815
  // mcp/git.ts
182743
182816
  import { randomUUID as randomUUID4 } from "node:crypto";
182744
182817
  import { writeFileSync as writeFileSync9 } from "node:fs";
182745
- import { join as join13 } from "node:path";
182818
+ import { join as join14 } from "node:path";
182746
182819
 
182747
182820
  // utils/apiCommit.ts
182748
182821
  import { execFileSync as execFileSync5 } from "node:child_process";
182749
182822
  import { lstat, readlink } from "node:fs/promises";
182750
- import { join as join11 } from "node:path";
182823
+ import { join as join12 } from "node:path";
182751
182824
  var GITHUB_API = "https://api.github.com";
182752
182825
  var MAX_BLOB_BYTES = 30 * 1024 * 1024;
182753
182826
  var BLOB_UPLOAD_CONCURRENCY = 8;
@@ -182812,7 +182885,7 @@ async function assertApiCommittable(files) {
182812
182885
  const root = getRepoRoot();
182813
182886
  const attrs = $(
182814
182887
  "git",
182815
- ["check-attr", "filter", "-z", "--", ...present.map((p) => join11(root, p))],
182888
+ ["check-attr", "filter", "-z", "--", ...present.map((p) => join12(root, p))],
182816
182889
  { log: false }
182817
182890
  );
182818
182891
  const parts = attrs.split("\0");
@@ -182824,7 +182897,7 @@ async function assertApiCommittable(files) {
182824
182897
  }
182825
182898
  }
182826
182899
  for (const path4 of present) {
182827
- const stat = await lstat(join11(root, path4));
182900
+ const stat = await lstat(join12(root, path4));
182828
182901
  if (stat.isDirectory()) {
182829
182902
  throw new Error(
182830
182903
  `'${path4}' is a directory (nested repository or submodule?) \u2014 signed commits only support files and symlinks.`
@@ -182833,7 +182906,7 @@ async function assertApiCommittable(files) {
182833
182906
  }
182834
182907
  }
182835
182908
  async function createBlobEntry(params) {
182836
- const absPath = join11(params.repoRoot, params.path);
182909
+ const absPath = join12(params.repoRoot, params.path);
182837
182910
  const stat = await lstat(absPath);
182838
182911
  if (stat.size > MAX_BLOB_BYTES) {
182839
182912
  throw new Error(
@@ -183013,7 +183086,7 @@ this usually means your local branch has commits that were never pushed \u2014 s
183013
183086
  var core4 = __toESM(require_core(), 1);
183014
183087
  import { createSign } from "node:crypto";
183015
183088
  import { rename, writeFile } from "node:fs/promises";
183016
- import { dirname as dirname3, join as join12 } from "node:path";
183089
+ import { dirname as dirname3, join as join13 } from "node:path";
183017
183090
 
183018
183091
  // node_modules/.pnpm/@octokit+plugin-throttling@11.0.5_@octokit+core@7.0.8/node_modules/@octokit/plugin-throttling/dist-bundle/index.js
183019
183092
  var import_light = __toESM(require_light(), 1);
@@ -187562,7 +187635,7 @@ function getGitHubUsageSummary() {
187562
187635
  }
187563
187636
  async function writeGitHubUsageSummaryToFile(path4) {
187564
187637
  const summary2 = getGitHubUsageSummary();
187565
- const tmpPath = join12(dirname3(path4), `.usage-summary-${process.pid}.tmp`);
187638
+ const tmpPath = join13(dirname3(path4), `.usage-summary-${process.pid}.tmp`);
187566
187639
  await writeFile(tmpPath, JSON.stringify(summary2));
187567
187640
  await rename(tmpPath, path4);
187568
187641
  }
@@ -188190,7 +188263,7 @@ function countAhead(head, base, cwd) {
188190
188263
  function spillGitOutput(params) {
188191
188264
  const tempDir = process.env.PULLFROG_TEMP_DIR;
188192
188265
  if (!tempDir) throw new Error("PULLFROG_TEMP_DIR not set");
188193
- const outputPath = join13(tempDir, `git-${params.command}-${randomUUID4().slice(0, 8)}.txt`);
188266
+ const outputPath = join14(tempDir, `git-${params.command}-${randomUUID4().slice(0, 8)}.txt`);
188194
188267
  writeFileSync9(outputPath, params.output);
188195
188268
  const previewByLines = params.output.split("\n").slice(0, OVERFLOW_PREVIEW_LINES).join("\n");
188196
188269
  const preview = previewByLines.length <= OVERFLOW_PREVIEW_MAX_CHARS ? previewByLines : `${previewByLines.slice(0, OVERFLOW_PREVIEW_MAX_CHARS)}\u2026`;
@@ -189845,11 +189918,11 @@ async function reportReviewNodeId(ctx, params) {
189845
189918
 
189846
189919
  // utils/setup.ts
189847
189920
  import { execFileSync as execFileSync6, execSync as execSync2 } from "node:child_process";
189848
- import { mkdtempSync, readdirSync, realpathSync as realpathSync2, rmSync as rmSync2, unlinkSync as unlinkSync2 } from "node:fs";
189921
+ import { mkdtempSync as mkdtempSync2, readdirSync, realpathSync as realpathSync2, rmSync as rmSync2, unlinkSync as unlinkSync2 } from "node:fs";
189849
189922
  import { tmpdir as tmpdir2 } from "node:os";
189850
- import { join as join14 } from "node:path";
189923
+ import { join as join15 } from "node:path";
189851
189924
  function createTempDirectory() {
189852
- const path4 = mkdtempSync(join14(tmpdir2(), "pullfrog-"));
189925
+ const path4 = mkdtempSync2(join15(tmpdir2(), "pullfrog-"));
189853
189926
  process.env.PULLFROG_TEMP_DIR = path4;
189854
189927
  log.info(`\xBB created temp dir at ${path4}`);
189855
189928
  return { path: path4, [Symbol.dispose]: () => removeTempDirectory(path4) };
@@ -189909,13 +189982,13 @@ function wipeRunnerLeakSurface() {
189909
189982
  return [];
189910
189983
  }
189911
189984
  };
189912
- const fileCommandsDir = join14(runnerTemp, "_runner_file_commands");
189985
+ const fileCommandsDir = join15(runnerTemp, "_runner_file_commands");
189913
189986
  for (const entry of listDir(fileCommandsDir)) {
189914
- tryUnlink(join14(fileCommandsDir, entry));
189987
+ tryUnlink(join15(fileCommandsDir, entry));
189915
189988
  }
189916
189989
  for (const entry of listDir(runnerTemp)) {
189917
189990
  if (entry.endsWith(".sh") || /^git-credentials-.*\.config$/.test(entry)) {
189918
- tryUnlink(join14(runnerTemp, entry));
189991
+ tryUnlink(join15(runnerTemp, entry));
189919
189992
  }
189920
189993
  }
189921
189994
  if (wiped.length > 0) {
@@ -190187,7 +190260,7 @@ async function ensureBeforeShaReachable(params) {
190187
190260
  "origin",
190188
190261
  tempBranch
190189
190262
  ],
190190
- { token: params.gitToken, refreshGitToken: params.refreshGitToken },
190263
+ { token: params.gitToken, refreshGitToken: params.refreshGitToken, signal: params.signal },
190191
190264
  `before_sha temp branch ${tempBranch}`
190192
190265
  );
190193
190266
  log.debug(`\xBB fetched before_sha via temp branch ${tempBranch}`);
@@ -190228,7 +190301,7 @@ function cleanupStaleGitLocks() {
190228
190301
  } catch {
190229
190302
  continue;
190230
190303
  }
190231
- if (now - mtimeMs < STALE_LOCK_AGE_MS) continue;
190304
+ if (now - mtimeMs < STALE_LOCK_AGE_MS || mtimeMs >= performance.timeOrigin) continue;
190232
190305
  try {
190233
190306
  unlinkSync3(relPath);
190234
190307
  log.warning(`\xBB removed stale ${relPath} from prior run`);
@@ -190261,7 +190334,7 @@ async function abortIfPullRequestMoved(args2) {
190261
190334
  );
190262
190335
  }
190263
190336
  async function checkoutPrBranch(pr, params) {
190264
- const { octokit, owner, name, gitToken, refreshGitToken, toolState, beforeSha } = params;
190337
+ const { octokit, owner, name, gitToken, refreshGitToken, toolState, beforeSha, signal } = params;
190265
190338
  rejectIfLeadingDash(pr.baseRef, "PR base ref");
190266
190339
  rejectIfLeadingDash(pr.headRef, "PR head ref");
190267
190340
  const repoState = requireRepoState(toolState, owner, name);
@@ -190275,7 +190348,7 @@ async function checkoutPrBranch(pr, params) {
190275
190348
  log.debug(`\xBB fetching base branch (${pr.baseRef})...`);
190276
190349
  await $gitFetchWithDeepen(
190277
190350
  ["--no-tags", "origin", pr.baseRef],
190278
- { token: gitToken, refreshGitToken },
190351
+ { token: gitToken, refreshGitToken, signal },
190279
190352
  `base branch ${pr.baseRef}`
190280
190353
  );
190281
190354
  if (!alreadyOnBranch) {
@@ -190286,7 +190359,7 @@ async function checkoutPrBranch(pr, params) {
190286
190359
  try {
190287
190360
  await $gitFetchWithDeepen(
190288
190361
  ["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`],
190289
- { token: gitToken, refreshGitToken },
190362
+ { token: gitToken, refreshGitToken, signal },
190290
190363
  `PR #${pr.number}`
190291
190364
  );
190292
190365
  } catch (e) {
@@ -190311,7 +190384,8 @@ async function checkoutPrBranch(pr, params) {
190311
190384
  repo: name,
190312
190385
  gitToken,
190313
190386
  refreshGitToken,
190314
- isShallow
190387
+ isShallow,
190388
+ signal
190315
190389
  }) : false;
190316
190390
  if (isShallow) {
190317
190391
  let deepenDepth = 0;
@@ -190346,7 +190420,8 @@ async function checkoutPrBranch(pr, params) {
190346
190420
  if (deepenDepth) {
190347
190421
  log.debug(`\xBB deepening by ${deepenDepth} to reach merge base...`);
190348
190422
  await $git("fetch", [`--deepen=${deepenDepth}`, "--no-tags", "origin"], {
190349
- token: gitToken
190423
+ token: gitToken,
190424
+ signal
190350
190425
  });
190351
190426
  }
190352
190427
  }
@@ -190384,6 +190459,7 @@ async function checkoutPrBranch(pr, params) {
190384
190459
  remoteBranch: pr.headRef,
190385
190460
  localBranch
190386
190461
  };
190462
+ signal.throwIfAborted();
190387
190463
  const postCheckoutHook = await executeLifecycleHook({
190388
190464
  event: "post-checkout",
190389
190465
  script: params.postCheckoutScript,
@@ -190394,19 +190470,30 @@ async function checkoutPrBranch(pr, params) {
190394
190470
  }
190395
190471
  var inFlightCheckouts = /* @__PURE__ */ new Map();
190396
190472
  var CHECKOUT_DEADLINE_MS = 57e4;
190397
- function withCheckoutDeadline(promise2, pullNumber) {
190398
- let timer;
190399
- const deadline = new Promise((_, reject) => {
190400
- timer = setTimeout(
190473
+ var CHECKOUT_ABORT_GRACE_MS = 15e3;
190474
+ function withCheckoutDeadline(run, pullNumber) {
190475
+ const exceeded = `checkout_pr #${pullNumber} exceeded ${CHECKOUT_DEADLINE_MS / 1e3}s`;
190476
+ const retry = "retry the call to start a fresh attempt.";
190477
+ const controller = new AbortController();
190478
+ const abortTimer = setTimeout(
190479
+ () => controller.abort(new Error(`${exceeded} and was cancelled; ${retry}`)),
190480
+ CHECKOUT_DEADLINE_MS
190481
+ );
190482
+ let backstopTimer;
190483
+ const backstop = new Promise((_, reject) => {
190484
+ backstopTimer = setTimeout(
190401
190485
  () => reject(
190402
190486
  new Error(
190403
- `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.`
190487
+ `${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}`
190404
190488
  )
190405
190489
  ),
190406
- CHECKOUT_DEADLINE_MS
190490
+ CHECKOUT_DEADLINE_MS + CHECKOUT_ABORT_GRACE_MS
190407
190491
  );
190408
190492
  });
190409
- return Promise.race([promise2, deadline]).finally(() => clearTimeout(timer));
190493
+ return Promise.race([run(controller.signal), backstop]).finally(() => {
190494
+ clearTimeout(abortTimer);
190495
+ clearTimeout(backstopTimer);
190496
+ });
190410
190497
  }
190411
190498
  function headsEqual(a, b) {
190412
190499
  if (a.kind === "branch" && b.kind === "branch") return a.name === b.name;
@@ -190418,7 +190505,7 @@ function describeHead(h) {
190418
190505
  return `detached HEAD \`${h.sha}\``;
190419
190506
  }
190420
190507
  function CheckoutPrTool(ctx) {
190421
- const runCheckout = async (pull_number) => {
190508
+ const runCheckout = async (pull_number, signal) => {
190422
190509
  const prResponse = await ctx.octokit.rest.pulls.get({
190423
190510
  owner: ctx.repo.owner,
190424
190511
  repo: ctx.repo.name,
@@ -190447,7 +190534,8 @@ function CheckoutPrTool(ctx) {
190447
190534
  toolState: ctx.toolState,
190448
190535
  shell: ctx.payload.shell,
190449
190536
  postCheckoutScript: ctx.postCheckoutScript,
190450
- beforeSha: primary.beforeSha
190537
+ beforeSha: primary.beforeSha,
190538
+ signal
190451
190539
  });
190452
190540
  const tempDir = process.env.PULLFROG_TEMP_DIR;
190453
190541
  if (!tempDir) {
@@ -190469,7 +190557,7 @@ function CheckoutPrTool(ctx) {
190469
190557
  headSha: checkoutSha
190470
190558
  });
190471
190559
  if (incremental.status === "ok") {
190472
- incrementalDiffPath = join15(
190560
+ incrementalDiffPath = join16(
190473
190561
  tempDir,
190474
190562
  `pr-${pull_number}-${beforeShort}-${headShort}-incremental.diff`
190475
190563
  );
@@ -190486,7 +190574,7 @@ function CheckoutPrTool(ctx) {
190486
190574
  const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
190487
190575
  log.debug(`formatted diff preview (first 100 lines):
190488
190576
  ${diffPreview}`);
190489
- const diffPath = join15(tempDir, `pr-${pull_number}-${headShort}.diff`);
190577
+ const diffPath = join16(tempDir, `pr-${pull_number}-${headShort}.diff`);
190490
190578
  writeFileSync10(diffPath, formatResult.content);
190491
190579
  log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
190492
190580
  primary.diffCoverage = createDiffCoverageState({
@@ -190526,7 +190614,8 @@ ${diffPreview}`);
190526
190614
  files: formatResult.files,
190527
190615
  pullNumber: pull_number,
190528
190616
  baseSha: prResponse.data.base.sha,
190529
- headSha: checkoutSha
190617
+ headSha: checkoutSha,
190618
+ signal
190530
190619
  });
190531
190620
  impactPath = impact.path;
190532
190621
  log.info(
@@ -190632,7 +190721,10 @@ ${dirty}`
190632
190721
  );
190633
190722
  }
190634
190723
  }
190635
- const promise2 = withCheckoutDeadline(runCheckout(pull_number), pull_number);
190724
+ const promise2 = withCheckoutDeadline(
190725
+ (signal) => runCheckout(pull_number, signal),
190726
+ pull_number
190727
+ );
190636
190728
  inFlightCheckouts.set(pull_number, promise2);
190637
190729
  try {
190638
190730
  return await promise2;
@@ -190645,7 +190737,7 @@ ${dirty}`
190645
190737
 
190646
190738
  // mcp/checkSuite.ts
190647
190739
  import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync11 } from "node:fs";
190648
- import { join as join16 } from "node:path";
190740
+ import { join as join17 } from "node:path";
190649
190741
  var GetCheckSuiteLogs = type({
190650
190742
  check_suite_id: type.number.describe("the id from check_suite.id")
190651
190743
  });
@@ -190741,7 +190833,7 @@ function GetCheckSuiteLogsTool(ctx) {
190741
190833
  if (!tempDir) {
190742
190834
  throw new Error("PULLFROG_TEMP_DIR not set");
190743
190835
  }
190744
- const logsDir = join16(tempDir, "ci-logs");
190836
+ const logsDir = join17(tempDir, "ci-logs");
190745
190837
  mkdirSync8(logsDir, { recursive: true });
190746
190838
  const jobResults = [];
190747
190839
  for (const run of failedRuns) {
@@ -190768,7 +190860,7 @@ function GetCheckSuiteLogsTool(ctx) {
190768
190860
  );
190769
190861
  }
190770
190862
  const logsText = await logsResult.text();
190771
- const logPath = join16(logsDir, `job-${job.id}.log`);
190863
+ const logPath = join17(logsDir, `job-${job.id}.log`);
190772
190864
  writeFileSync11(logPath, logsText);
190773
190865
  const analysis = analyzeLog(logsText, 80);
190774
190866
  const failedSteps = job.steps?.filter((s) => s.conclusion === "failure").map((s) => `Step ${s.number}: ${s.name}`) ?? [];
@@ -190818,7 +190910,7 @@ function GetCheckSuiteLogsTool(ctx) {
190818
190910
 
190819
190911
  // mcp/commitInfo.ts
190820
190912
  import { writeFileSync as writeFileSync12 } from "node:fs";
190821
- import { join as join17 } from "node:path";
190913
+ import { join as join18 } from "node:path";
190822
190914
  var CommitInfo = type({
190823
190915
  sha: type.string.describe("the commit SHA (full or abbreviated) to fetch")
190824
190916
  });
@@ -190842,7 +190934,7 @@ function CommitInfoTool(ctx) {
190842
190934
  "PULLFROG_TEMP_DIR not set - get_commit_info must run in pullfrog action context"
190843
190935
  );
190844
190936
  }
190845
- const diffFile = join17(tempDir, `commit-${sha.slice(0, 7)}.diff`);
190937
+ const diffFile = join18(tempDir, `commit-${sha.slice(0, 7)}.diff`);
190846
190938
  writeFileSync12(diffFile, formatResult.content);
190847
190939
  log.debug(`wrote commit diff to ${diffFile} (${formatResult.content.length} bytes)`);
190848
190940
  return {
@@ -190979,14 +191071,14 @@ function ReopenCurrentTool(ctx) {
190979
191071
  import { performance as performance8 } from "node:perf_hooks";
190980
191072
 
190981
191073
  // prep/installNodeDependencies.ts
190982
- import { existsSync as existsSync8 } from "node:fs";
190983
- import { join as join19 } from "node:path";
191074
+ import { existsSync as existsSync9 } from "node:fs";
191075
+ import { join as join20 } from "node:path";
190984
191076
 
190985
191077
  // utils/packageManager.ts
190986
191078
  var import_semver2 = __toESM(require_semver2(), 1);
190987
- import { existsSync as existsSync7 } from "node:fs";
191079
+ import { existsSync as existsSync8 } from "node:fs";
190988
191080
  import { mkdir, readFile as readFile2 } from "node:fs/promises";
190989
- import { delimiter, join as join18 } from "node:path";
191081
+ import { delimiter, join as join19 } from "node:path";
190990
191082
  var SUPPORTED_NAMES = ["npm", "pnpm", "yarn", "bun"];
190991
191083
  var COREPACK_MANAGED = ["pnpm", "yarn"];
190992
191084
  function isSupported(name) {
@@ -191024,8 +191116,8 @@ function parseDevEnginesField(field) {
191024
191116
  };
191025
191117
  }
191026
191118
  async function resolvePackageManagerSpec(cwd) {
191027
- const pkgPath = join18(cwd, "package.json");
191028
- if (!existsSync7(pkgPath)) return null;
191119
+ const pkgPath = join19(cwd, "package.json");
191120
+ if (!existsSync8(pkgPath)) return null;
191029
191121
  let pkg;
191030
191122
  try {
191031
191123
  pkg = JSON.parse(await readFile2(pkgPath, "utf-8"));
@@ -191083,7 +191175,7 @@ async function currentVersion(name) {
191083
191175
  return result.stdout.trim();
191084
191176
  }
191085
191177
  function packageManagerBinDir(tmpdir3) {
191086
- return join18(tmpdir3, "pm-bin");
191178
+ return join19(tmpdir3, "pm-bin");
191087
191179
  }
191088
191180
  async function ensurePackageManager(params) {
191089
191181
  const spec = params.spec;
@@ -191146,7 +191238,7 @@ async function installFallback(name, installSpec) {
191146
191238
  });
191147
191239
  if (result.exitCode !== 0) return result.stderr || `failed to install ${name}`;
191148
191240
  if (name === "deno") {
191149
- const denoPath = join18(process.env.HOME || "", ".deno", "bin");
191241
+ const denoPath = join19(process.env.HOME || "", ".deno", "bin");
191150
191242
  process.env.PATH = `${denoPath}:${process.env.PATH}`;
191151
191243
  }
191152
191244
  log.info(`\xBB installed ${name}`);
@@ -191179,8 +191271,8 @@ async function isCommandAvailable2(command) {
191179
191271
  var installNodeDependencies = {
191180
191272
  name: "installNodeDependencies",
191181
191273
  shouldRun: () => {
191182
- const packageJsonPath = join19(process.cwd(), "package.json");
191183
- return existsSync8(packageJsonPath);
191274
+ const packageJsonPath = join20(process.cwd(), "package.json");
191275
+ return existsSync9(packageJsonPath);
191184
191276
  },
191185
191277
  run: async (options) => {
191186
191278
  const declared = await resolvePackageManagerSpec(process.cwd());
@@ -191274,14 +191366,14 @@ ${errorMessage}`]
191274
191366
  };
191275
191367
 
191276
191368
  // prep/installPythonDependencies.ts
191277
- import { existsSync as existsSync9, readFileSync as readFileSync5 } from "node:fs";
191278
- import { join as join20 } from "node:path";
191369
+ import { existsSync as existsSync10, readFileSync as readFileSync5 } from "node:fs";
191370
+ import { join as join21 } from "node:path";
191279
191371
  function declaresBuildSystem(path4) {
191280
191372
  return /^\s*\[\s*build-system\s*\]/m.test(readFileSync5(path4, "utf8"));
191281
191373
  }
191282
191374
  function configApplies(config3, cwd) {
191283
- const path4 = join20(cwd, config3.file);
191284
- return existsSync9(path4) && (config3.applies?.(path4) ?? true);
191375
+ const path4 = join21(cwd, config3.file);
191376
+ return existsSync10(path4) && (config3.applies?.(path4) ?? true);
191285
191377
  }
191286
191378
  var PYTHON_CONFIGS = [
191287
191379
  {
@@ -191603,7 +191695,7 @@ function AwaitDependencyInstallationTool(ctx) {
191603
191695
 
191604
191696
  // mcp/gh.ts
191605
191697
  import { mkdirSync as mkdirSync9 } from "node:fs";
191606
- import { join as join21 } from "node:path";
191698
+ import { join as join22 } from "node:path";
191607
191699
  var GH_TIMEOUT_MS = 12e4;
191608
191700
  var GhParams = type({
191609
191701
  args: type.string.array().describe(
@@ -191625,7 +191717,7 @@ function GhTool(ctx) {
191625
191717
  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.`,
191626
191718
  parameters: GhParams,
191627
191719
  execute: execute(async (params) => {
191628
- const configDir = join21(ctx.tmpdir, "gh-config");
191720
+ const configDir = join22(ctx.tmpdir, "gh-config");
191629
191721
  mkdirSync9(configDir, { recursive: true });
191630
191722
  const command = ["gh", ...params.args.map(shellQuote)].join(" ");
191631
191723
  const result = await runSandboxed({
@@ -192349,7 +192441,7 @@ function PullRequestInfoTool(ctx) {
192349
192441
 
192350
192442
  // mcp/reviewComments.ts
192351
192443
  import { writeFileSync as writeFileSync13 } from "node:fs";
192352
- import { join as join22 } from "node:path";
192444
+ import { join as join23 } from "node:path";
192353
192445
  var COMMENTS_PER_THREAD = 50;
192354
192446
  var REVIEW_THREADS_QUERY = `
192355
192447
  query ($owner: String!, $name: String!, $prNumber: Int!, $cursor: String) {
@@ -192843,7 +192935,7 @@ function GetReviewCommentsTool(ctx) {
192843
192935
  throw new Error("PULLFROG_TEMP_DIR not set");
192844
192936
  }
192845
192937
  const filename = `review-${params.review_id}-threads.md`;
192846
- const commentsPath = join22(tempDir, filename);
192938
+ const commentsPath = join23(tempDir, filename);
192847
192939
  writeFileSync13(commentsPath, formatted.content);
192848
192940
  log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`);
192849
192941
  return {
@@ -193213,7 +193305,7 @@ function UploadFileTool(ctx) {
193213
193305
 
193214
193306
  // mcp/xrepo.ts
193215
193307
  import { mkdirSync as mkdirSync10, rmSync as rmSync3 } from "node:fs";
193216
- import { join as join23 } from "node:path";
193308
+ import { join as join24 } from "node:path";
193217
193309
  function assertValidRepoName(name) {
193218
193310
  if (!/^[A-Za-z0-9._-]+$/.test(name) || name.startsWith("-") || name.includes("..")) {
193219
193311
  throw new Error(
@@ -193293,7 +193385,7 @@ function CheckoutRepoTool(ctx) {
193293
193385
  return { path: existing.dir, access: existing.access, note: "already checked out." };
193294
193386
  }
193295
193387
  const access = accessFor(ctx, repo);
193296
- const dir = join23(ctx.tmpdir, "xrepo", repo);
193388
+ const dir = join24(ctx.tmpdir, "xrepo", repo);
193297
193389
  mkdirSync10(dir, { recursive: true });
193298
193390
  const state = ensureRepoState(ctx.toolState, { owner, name: repo, dir, access });
193299
193391
  const rc = resolveRepoCtx(ctx, repo);
@@ -193659,6 +193751,7 @@ function isByokSetupError(text) {
193659
193751
  var SECRETS_UNAVAILABLE_MARKER = "couldn't load your Pullfrog secrets";
193660
193752
  var ROUTER_UNFUNDED_MARKER = "your Pullfrog Router balance is empty";
193661
193753
  var CREDENTIAL_REJECTED_MARKER = "was rejected by its provider";
193754
+ var CREDENTIAL_POOL_REFUSED_MARKER = "No configured credential can serve this run";
193662
193755
  var NoUsableCredentialError = class extends Error {
193663
193756
  };
193664
193757
  function throwKeyError(params) {
@@ -193925,7 +194018,8 @@ function isOAuthCredentialExpiredError(text) {
193925
194018
  text
193926
194019
  ) || // the provider no longer recognises the token at all (#1086) — same dead
193927
194020
  // credential, phrased as a lookup miss rather than a state transition.
193928
- /Could not find the appropriate key in your authentication token/i.test(text) || /Token refresh failed/i.test(text)
194021
+ /Could not find the appropriate key in your authentication token/i.test(text) || // the reversed word order: `Encountered invalidated oauth token for user` (#1474).
194022
+ /(?:expired|invalidated|revoked) (?:OAuth|authentication) token/i.test(text) || /Token refresh failed/i.test(text)
193929
194023
  );
193930
194024
  }
193931
194025
  function isMalformedKeyError(text) {
@@ -193972,7 +194066,7 @@ function buildRejectedCredentialError(params) {
193972
194066
  ].join("\n");
193973
194067
  }
193974
194068
  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)`;
193975
- const keysUrl = providerKeysUrl(params.credential);
194069
+ const keysUrl = providerDashboard(params.credential)?.keys;
193976
194070
  const issue3 = keysUrl ? `[Issue a new key \u2192](${keysUrl}) \xB7 ` : "";
193977
194071
  return [
193978
194072
  `**Your \`${params.credential}\` ${CREDENTIAL_REJECTED_MARKER}**${detail}, so the agent never ran.`,
@@ -193982,12 +194076,43 @@ function buildRejectedCredentialError(params) {
193982
194076
  `${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)`
193983
194077
  ].join("\n");
193984
194078
  }
193985
- function providerKeysUrl(credential) {
194079
+ function providerDashboard(credential) {
193986
194080
  for (const [id, provider2] of Object.entries(providers)) {
193987
- if (provider2.envVars.includes(credential)) return PROVIDER_DASHBOARDS[id]?.keys;
194081
+ if (provider2.envVars.includes(credential)) return PROVIDER_DASHBOARDS[id];
193988
194082
  }
193989
194083
  return void 0;
193990
194084
  }
194085
+ function buildCredentialPoolRefusedError(params) {
194086
+ const settingsUrl = `${getApiUrl()}/console/${params.owner}/${params.name}`;
194087
+ return [
194088
+ `**${CREDENTIAL_POOL_REFUSED_MARKER}.** Every credential configured for \`${params.model}\` was rejected or is out of quota, so the agent never ran.`,
194089
+ "",
194090
+ ...params.refused.map(
194091
+ (credential) => `- ${describeRefusal({ ...params, credential, settingsUrl })}`
194092
+ ),
194093
+ "",
194094
+ `[Model settings \u2192](${settingsUrl}) \xB7 [Ask in Discord \u2192](https://discord.gg/8y96raFg8e)`
194095
+ ].join("\n");
194096
+ }
194097
+ function describeRefusal(params) {
194098
+ const credential = params.credential;
194099
+ const verdict = credential.verdict;
194100
+ const workflow = credential.source === "workflow";
194101
+ const head = `\`${credential.name}\` from ${workflow ? "the workflow" : `${credential.source} scope`}: \`${verdict.detail}\` \u2014`;
194102
+ const subscription = SUBSCRIPTION_CREDENTIALS[credential.name];
194103
+ const dashboard = providerDashboard(credential.name);
194104
+ if (verdict.status === "exhausted" && subscription) {
194105
+ 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";
194106
+ return `${head} ${wait}, or add an API key for this provider, which Pullfrog falls back to automatically.`;
194107
+ }
194108
+ if (verdict.status === "exhausted")
194109
+ return `${head} add credit or raise its quota with the provider.${dashboard ? ` [Top up \u2192](${dashboard.billing})` : ""}`;
194110
+ if (subscription)
194111
+ 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})`;
194112
+ 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})`;
194113
+ const issue3 = dashboard?.keys ? `[Issue a new key \u2192](${dashboard.keys}) \xB7 ` : "";
194114
+ return `${head} issue a new key and update the copy Pullfrog uses. ${issue3}${where}`;
194115
+ }
193991
194116
  function formatApiKeyErrorSummary(params) {
193992
194117
  if (params.raw.includes(MISSING_KEY_MARKER)) {
193993
194118
  if (params.raw.startsWith(`**${MISSING_KEY_MARKER}**`)) return params.raw;
@@ -194760,11 +194885,16 @@ async function selectConfiguredCredential(input) {
194760
194885
  if (option.candidate) log.info(`\xBB selected ${option.name} from ${from}`);
194761
194886
  return true;
194762
194887
  }
194763
- refused.push(`${option.name} from ${from}: ${verdict.detail}`);
194888
+ refused.push({ name: option.name, source: option.candidate?.source ?? "workflow", verdict });
194764
194889
  log.info(`\xBB ${option.name} from ${from} unavailable; trying the next credential`);
194765
194890
  }
194766
194891
  throw new Error(
194767
- `all configured credentials for ${model} were rejected or exhausted (${refused.join("; ")}); no other model or provider was selected`
194892
+ buildCredentialPoolRefusedError({
194893
+ model,
194894
+ refused,
194895
+ owner: input.ctx.repo.owner,
194896
+ name: input.ctx.repo.name
194897
+ })
194768
194898
  );
194769
194899
  }
194770
194900
  async function probe(input) {
@@ -194805,7 +194935,7 @@ function activate(input) {
194805
194935
  import { randomUUID as randomUUID6 } from "node:crypto";
194806
194936
  import { writeFileSync as writeFileSync14 } from "node:fs";
194807
194937
  import { createServer as createServer3 } from "node:http";
194808
- import { join as join24 } from "node:path";
194938
+ import { join as join25 } from "node:path";
194809
194939
  var REVOKED_TRAP_MS = 6e4;
194810
194940
  function revokeGitHubToken(token) {
194811
194941
  fetch("https://api.github.com/installation/token", {
@@ -194874,7 +195004,7 @@ async function startGitAuthServer(tmpdir3) {
194874
195004
  function writeAskpassScript(code) {
194875
195005
  const scriptId = randomUUID6();
194876
195006
  const scriptName = `askpass-${scriptId}.js`;
194877
- const scriptPath = join24(tmpdir3, scriptName);
195007
+ const scriptPath = join25(tmpdir3, scriptName);
194878
195008
  const content = [
194879
195009
  `#!/usr/bin/env node`,
194880
195010
  `var a=process.argv[2]||"";`,
@@ -195323,7 +195453,7 @@ function resolveInstructions(ctx) {
195323
195453
 
195324
195454
  // utils/learnings.ts
195325
195455
  import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
195326
- import { dirname as dirname4, join as join25 } from "node:path";
195456
+ import { dirname as dirname4, join as join26 } from "node:path";
195327
195457
 
195328
195458
  // utils/learningsTruncate.ts
195329
195459
  var MAX_LEARNINGS_LENGTH = 1e5;
@@ -195341,10 +195471,10 @@ function truncateAtLineBoundary(body, cap) {
195341
195471
  var LEARNINGS_FILE_NAME = "pullfrog-learnings.md";
195342
195472
  var XREPO_LEARNINGS_FILE_NAME = "pullfrog-xrepo-learnings.md";
195343
195473
  function learningsFilePath(tmpdir3) {
195344
- return join25(tmpdir3, LEARNINGS_FILE_NAME);
195474
+ return join26(tmpdir3, LEARNINGS_FILE_NAME);
195345
195475
  }
195346
195476
  function xrepoLearningsFilePath(tmpdir3) {
195347
- return join25(tmpdir3, XREPO_LEARNINGS_FILE_NAME);
195477
+ return join26(tmpdir3, XREPO_LEARNINGS_FILE_NAME);
195348
195478
  }
195349
195479
  async function seedLearningsFile(params) {
195350
195480
  const path4 = learningsFilePath(params.tmpdir);
@@ -195895,7 +196025,9 @@ async function resolveTokens(params) {
195895
196025
  // commit-status check-runs for branch protection. the app already grants
195896
196026
  // checks:write; this scopes the MCP token up to use it.
195897
196027
  checks: "write",
195898
- actions: "read"
196028
+ // write (not read) so postReviewCleanup can dispatch the follow-up re-review
196029
+ // for commits pushed while a review was in flight; read 403s it.
196030
+ actions: "write"
195899
196031
  };
195900
196032
  const mcpToken = await acquireNewToken({
195901
196033
  repos: writeRepos,
@@ -196281,7 +196413,7 @@ async function resolveTrialFallback(ctx) {
196281
196413
 
196282
196414
  // utils/prSummary.ts
196283
196415
  import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
196284
- import { dirname as dirname5, join as join26 } from "node:path";
196416
+ import { dirname as dirname5, join as join27 } from "node:path";
196285
196417
  var SUMMARY_FILE_NAME = "pullfrog-summary.md";
196286
196418
  var SUMMARY_SCAFFOLD = `# PR summary
196287
196419
 
@@ -196291,7 +196423,7 @@ var SUMMARY_SCAFFOLD = `# PR summary
196291
196423
  var MIN_SNAPSHOT_LENGTH = 60;
196292
196424
  var MAX_SNAPSHOT_LENGTH = 32768;
196293
196425
  function summaryFilePath(tmpdir3) {
196294
- return join26(tmpdir3, SUMMARY_FILE_NAME);
196426
+ return join27(tmpdir3, SUMMARY_FILE_NAME);
196295
196427
  }
196296
196428
  async function seedSummaryFile(params) {
196297
196429
  const path4 = summaryFilePath(params.tmpdir);
@@ -196560,7 +196692,7 @@ function formatBillingExhaustedBody(diagnostic) {
196560
196692
 
196561
196693
  // utils/runErrorRenderer.ts
196562
196694
  function isProviderModelNotFoundError(message) {
196563
- return message.includes("ProviderModelNotFoundError");
196695
+ return /ProviderModelNotFoundError|\bModel not found: /.test(message);
196564
196696
  }
196565
196697
  function isContextOverflowError(message) {
196566
196698
  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);
@@ -196657,6 +196789,7 @@ function detectProviderId(message) {
196657
196789
  if (/requires more credits|Key limit exceeded \(total limit\)|openrouter\.ai/i.test(message)) {
196658
196790
  return "openrouter";
196659
196791
  }
196792
+ if (/platform\.openai\.com/i.test(message)) return "openai";
196660
196793
  return null;
196661
196794
  }
196662
196795
  var OPENROUTER_PRIVACY_URL = "https://openrouter.ai/settings/privacy";
@@ -196754,12 +196887,38 @@ function formatByokSetupSummary(input) {
196754
196887
  ].join("\n");
196755
196888
  }
196756
196889
  function formatProviderModelNotFoundSummary(input) {
196757
- 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.
196890
+ 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.
196758
196891
 
196759
196892
  \`\`\`
196760
196893
  ${input.raw}
196761
196894
  \`\`\``;
196762
196895
  }
196896
+ function formatProviderUsageLimit(input) {
196897
+ return [
196898
+ "**The provider's usage limit for this account has been reached.**",
196899
+ "",
196900
+ "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.",
196901
+ "",
196902
+ `[Model settings \u2192](${getApiUrl()}/console/${input.owner}/${input.name}) \xB7 [Ask in Discord \u2192](https://discord.gg/8y96raFg8e)`,
196903
+ "",
196904
+ `\`\`\`
196905
+ ${input.raw}
196906
+ \`\`\``
196907
+ ].join("\n");
196908
+ }
196909
+ function formatProviderErrorBody(input) {
196910
+ return [
196911
+ "**The model provider returned an error, so this run stopped.**",
196912
+ "",
196913
+ "The error is below. If it names your key, plan or balance, fix that with the provider or pick another model before re-triggering.",
196914
+ "",
196915
+ `[Model settings \u2192](${getApiUrl()}/console/${input.owner}/${input.name}) \xB7 [Ask in Discord \u2192](https://discord.gg/8y96raFg8e)`,
196916
+ "",
196917
+ `\`\`\`
196918
+ ${input.raw}
196919
+ \`\`\``
196920
+ ].join("\n");
196921
+ }
196763
196922
  function renderRunError(input) {
196764
196923
  const billingError = input.routerActive && isRouterKeylimitExhaustedError(input.errorMessage) ? new BillingError(input.errorMessage, { code: "router_keylimit_exhausted" }) : null;
196765
196924
  if (billingError) {
@@ -196775,7 +196934,7 @@ function renderRunError(input) {
196775
196934
  if (input.errorMessage.includes(ROUTER_UNFUNDED_MARKER)) {
196776
196935
  return { summary: input.errorMessage, comment: input.errorMessage };
196777
196936
  }
196778
- if (input.errorMessage.includes(CREDENTIAL_REJECTED_MARKER)) {
196937
+ if (input.errorMessage.includes(CREDENTIAL_REJECTED_MARKER) || input.errorMessage.includes(CREDENTIAL_POOL_REFUSED_MARKER)) {
196779
196938
  return { summary: input.errorMessage, comment: input.errorMessage };
196780
196939
  }
196781
196940
  const isHang = input.errorMessage.startsWith("activity timeout") || input.errorMessage.startsWith("agent still pending");
@@ -196798,6 +196957,16 @@ ${body}`, comment: body };
196798
196957
  });
196799
196958
  return { summary: `### \u274C Pullfrog failed
196800
196959
 
196960
+ ${body}`, comment: body };
196961
+ }
196962
+ if (isProviderUsageLimit(input.errorMessage)) {
196963
+ const body = formatProviderUsageLimit({
196964
+ owner: input.repo.owner,
196965
+ name: input.repo.name,
196966
+ raw: input.errorMessage
196967
+ });
196968
+ return { summary: `### \u274C Pullfrog failed
196969
+
196801
196970
  ${body}`, comment: body };
196802
196971
  }
196803
196972
  if (isProviderMissingCredential(input.errorMessage)) {
@@ -196892,6 +197061,16 @@ ${hangBody}`,
196892
197061
  comment: explained ? hangBody : formatMinimalFailureComment(input.repo)
196893
197062
  };
196894
197063
  }
197064
+ if (input.errorMessage.startsWith("provider error: ")) {
197065
+ const body = formatProviderErrorBody({
197066
+ owner: input.repo.owner,
197067
+ name: input.repo.name,
197068
+ raw: input.errorMessage
197069
+ });
197070
+ return { summary: `### \u274C Pullfrog failed
197071
+
197072
+ ${body}`, comment: body };
197073
+ }
196895
197074
  const genericBody = formatGenericFailure(input.errorMessage);
196896
197075
  return {
196897
197076
  summary: `### \u274C Pullfrog failed
@@ -197702,7 +197881,7 @@ async function main() {
197702
197881
  const repoDir = payload.cwd;
197703
197882
  const preIntrospectionDirty = repoDir ? await dirtyTrackedPaths({ cwd: repoDir }) : null;
197704
197883
  const opencodeCliPath = await agents.opencode.install();
197705
- captureBaselineModels(opencodeCliPath);
197884
+ captureBaselineModels({ cliPath: opencodeCliPath, tmpdir: tmpdir3 });
197706
197885
  const injected = [];
197707
197886
  if (runContext.dbSecrets) {
197708
197887
  for (const [key, value2] of Object.entries(runContext.dbSecrets)) {
@@ -197719,7 +197898,7 @@ async function main() {
197719
197898
  await initializeCredentialPool({ ctx: runContext, slug: payload.model, injected });
197720
197899
  installCodexAuth();
197721
197900
  installXaiAuth();
197722
- captureAuthorizedModels(opencodeCliPath);
197901
+ captureAuthorizedModels({ cliPath: opencodeCliPath, tmpdir: tmpdir3 });
197723
197902
  if (preIntrospectionDirty && repoDir) {
197724
197903
  await restoreDirtiedSince({
197725
197904
  before: preIntrospectionDirty,
@@ -198057,8 +198236,8 @@ ${instructions.user}` : null,
198057
198236
  log.info(instructions.full);
198058
198237
  });
198059
198238
  if (agentId === "opencode") {
198060
- const pluginDir = join27(process.cwd(), ".opencode", "plugin");
198061
- const hasPlugins = existsSync10(pluginDir) && readdirSync2(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
198239
+ const pluginDir = join28(process.cwd(), ".opencode", "plugin");
198240
+ const hasPlugins = existsSync11(pluginDir) && readdirSync2(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
198062
198241
  if (hasPlugins && toolState.dependencyInstallation?.promise) {
198063
198242
  log.info(
198064
198243
  "\xBB .opencode/plugin/ detected \u2014 awaiting dependency installation before agent start"