pullfrog 0.1.96 → 0.1.98

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 = []) {
@@ -103333,8 +103333,9 @@ var providers = {
103333
103333
  },
103334
103334
  "claude-haiku": {
103335
103335
  displayName: "Claude Haiku",
103336
- resolve: "anthropic/claude-haiku-4-5",
103337
- openRouterResolve: "openrouter/anthropic/claude-haiku-4.5"
103336
+ resolve: "anthropic/claude-haiku-5-5",
103337
+ effort: ["low", "medium", "high", "xhigh", "max"],
103338
+ openRouterResolve: "openrouter/anthropic/claude-haiku-5.5"
103338
103339
  }
103339
103340
  }
103340
103341
  }),
@@ -103752,8 +103753,9 @@ var providers = {
103752
103753
  },
103753
103754
  "claude-haiku": {
103754
103755
  displayName: "Claude Haiku",
103755
- resolve: "opencode/claude-haiku-4-5",
103756
- openRouterResolve: "openrouter/anthropic/claude-haiku-4.5"
103756
+ resolve: "opencode/claude-haiku-5-5",
103757
+ effort: ["low", "medium", "high", "xhigh", "max"],
103758
+ openRouterResolve: "openrouter/anthropic/claude-haiku-5.5"
103757
103759
  },
103758
103760
  "gpt-sol": {
103759
103761
  displayName: "GPT Sol",
@@ -103909,6 +103911,14 @@ var providers = {
103909
103911
  openRouterEffort: [],
103910
103912
  openRouterResolve: "openrouter/qwen/qwen3.8-flash"
103911
103913
  },
103914
+ // Zen lists Mistral Large 4 at half its list price for launch, level with
103915
+ // the routers.
103916
+ "mistral-large": {
103917
+ displayName: "Mistral Large",
103918
+ resolve: "opencode/mistral-large-4",
103919
+ effort: ["none", "high"],
103920
+ openRouterResolve: "openrouter/mistralai/mistral-large-4-0"
103921
+ },
103912
103922
  "gpt-5-nano": {
103913
103923
  displayName: "GPT Nano",
103914
103924
  resolve: "opencode/gpt-5.4-nano",
@@ -103957,12 +103967,12 @@ var providers = {
103957
103967
  fallback: "opencode/big-pickle"
103958
103968
  },
103959
103969
  // 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.
103970
+ // big-pickle and union-alpha. Zen's docs: zero retention, no training.
103971
+ // unlike the first two it publishes an effort ladder, so the gate
103972
+ // mirrors one here.
103963
103973
  "space-bunny": {
103964
103974
  displayName: "Space Bunny",
103965
- description: "Stealth preview; prompts may be retained",
103975
+ description: "Stealth preview, free for a limited time",
103966
103976
  resolve: "opencode/space-bunny-free",
103967
103977
  effort: ["low", "medium", "high", "xhigh", "max"],
103968
103978
  // free to run, still gated on the provider's own OPENCODE_API_KEY —
@@ -103980,17 +103990,46 @@ var providers = {
103980
103990
  // see the big-pickle note above (#1077).
103981
103991
  isFree: true
103982
103992
  },
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.
103993
+ // Zen's FREE stealth preview (listed 2026-10-01), the fifth promo. Zen's
103994
+ // docs: data may be used to improve the model during the free period.
103985
103995
  "fledge-alpha": {
103986
103996
  displayName: "Fledge Alpha",
103987
- description: "Stealth preview, free for a limited time",
103997
+ description: "Stealth preview; data may be used to improve it",
103988
103998
  resolve: "opencode/fledge-alpha-free",
103989
103999
  effort: ["low", "high", "max"],
103990
104000
  // free to run, still gated on the provider's own OPENCODE_API_KEY —
103991
104001
  // see the big-pickle note above (#1077).
104002
+ isFree: true,
104003
+ // Zen dropped the id from `/v1/models` and its docs on 2026-10-08 and
104004
+ // answers a 500 for it; stored picks land on the other free stealth
104005
+ // model, as `union-alpha` does.
104006
+ fallback: "opencode/big-pickle"
104007
+ },
104008
+ // Zen's FREE preview (listed 2026-10-06), the sixth promo, on Fledge
104009
+ // Alpha's terms.
104010
+ exo: {
104011
+ displayName: "Exo",
104012
+ description: "Preview; data may be used to improve it",
104013
+ resolve: "opencode/exo-free",
104014
+ // Zen publishes a single rung.
104015
+ effort: ["high"],
104016
+ // free to run, still gated on the provider's own OPENCODE_API_KEY —
104017
+ // see the big-pickle note above (#1077).
103992
104018
  isFree: true
103993
104019
  },
104020
+ // Zen's FREE StepFun preview (listed 2026-10-08), the seventh promo. Zen's
104021
+ // docs: zero retention, no training. Go lists the same free id; one key
104022
+ // reaches both. hidden until a published `pullfrog` resolves it.
104023
+ "step-5-preview": {
104024
+ displayName: "Step 5 Preview",
104025
+ description: "StepFun preview, free for a limited time",
104026
+ resolve: "opencode/step-5-preview-free",
104027
+ effort: ["low", "medium", "high"],
104028
+ // free to run, still gated on the provider's own OPENCODE_API_KEY —
104029
+ // see the big-pickle note above (#1077).
104030
+ isFree: true,
104031
+ hidden: true
104032
+ },
103994
104033
  // Zen's live free MiMo, and the second free row in a menu that big-pickle
103995
104034
  // was alone in since `mimo-v2-pro-free` lost its model.
103996
104035
  mimo: {
@@ -104275,6 +104314,9 @@ var providers = {
104275
104314
  "claude-haiku": {
104276
104315
  displayName: "Claude Haiku",
104277
104316
  resolve: "openrouter/~anthropic/claude-haiku-latest",
104317
+ // the rolling alias moved to Haiku 5.5 on 2026-10-07, which publishes
104318
+ // the ladder Haiku 4.5 did not.
104319
+ effort: ["low", "medium", "high", "xhigh", "max"],
104278
104320
  openRouterResolve: "openrouter/~anthropic/claude-haiku-latest"
104279
104321
  },
104280
104322
  // pinned to the explicit Sol/Terra/Luna tiers (not the ~openai/gpt-latest
@@ -104446,6 +104488,14 @@ var providers = {
104446
104488
  resolve: "openrouter/meta/muse-spark-1.3-contributor",
104447
104489
  effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
104448
104490
  openRouterResolve: "openrouter/meta/muse-spark-1.3-contributor"
104491
+ },
104492
+ // Mistral's flagship (public preview, 2026-10-06), on the routers and
104493
+ // Zen only: the catalog carries no direct Mistral provider.
104494
+ "mistral-large": {
104495
+ displayName: "Mistral Large",
104496
+ resolve: "openrouter/mistralai/mistral-large-4-0",
104497
+ effort: ["none", "high"],
104498
+ openRouterResolve: "openrouter/mistralai/mistral-large-4-0"
104449
104499
  }
104450
104500
  }
104451
104501
  }),
@@ -104476,7 +104526,8 @@ var providers = {
104476
104526
  },
104477
104527
  "claude-haiku": {
104478
104528
  displayName: "Claude Haiku",
104479
- resolve: "vercel/anthropic/claude-haiku-4.5"
104529
+ resolve: "vercel/anthropic/claude-haiku-5.5",
104530
+ effort: ["none", "low", "medium", "high", "xhigh", "max"]
104480
104531
  },
104481
104532
  "gpt-sol": {
104482
104533
  displayName: "GPT Sol",
@@ -104537,6 +104588,11 @@ var providers = {
104537
104588
  displayName: "Muse Spark",
104538
104589
  resolve: "vercel/meta/muse-spark-1.3",
104539
104590
  effort: ["minimal", "low", "medium", "high", "xhigh"]
104591
+ },
104592
+ "mistral-large": {
104593
+ displayName: "Mistral Large",
104594
+ resolve: "vercel/mistral/mistral-large-4",
104595
+ effort: ["none", "low", "medium", "high"]
104540
104596
  }
104541
104597
  }
104542
104598
  })
@@ -105417,7 +105473,13 @@ var PROVIDER_ERROR_PATTERNS = [
105417
105473
  // …/settings/credits`) matched neither this nor `requires more credits`, so
105418
105474
  // the one run in that episode that was a genuinely empty wallet rendered with
105419
105475
  // no CTA at all while runs 1-6 rendered correctly.
105420
- { regex: /Insufficient (?:balance|credits)/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
105476
+ // `account funds` is Zen's (#1474).
105477
+ {
105478
+ regex: /Insufficient (?:balance|credits|account funds)/i,
105479
+ label: PROVIDER_BILLING_EXHAUSTED_LABEL
105480
+ },
105481
+ // OpenAI's `You have no credits remaining` (#1474).
105482
+ { regex: /no credits remaining/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
105421
105483
  { regex: /credit balance is too low/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
105422
105484
  // "spending cap" (Gemini) and "spending limit" (xAI, #1076) are the same
105423
105485
  // condition in two house styles — a configured ceiling was reached.
@@ -105538,6 +105600,9 @@ function isRouterKeylimitExhaustedError(text) {
105538
105600
  function isProviderBillingExhausted(text) {
105539
105601
  return findProviderErrorMatch(text)?.label === PROVIDER_BILLING_EXHAUSTED_LABEL;
105540
105602
  }
105603
+ function isProviderUsageLimit(text) {
105604
+ return findProviderErrorMatch(text)?.label === PROVIDER_USAGE_LIMIT_LABEL;
105605
+ }
105541
105606
  function isProviderNoRoutableEndpoints(text) {
105542
105607
  return findProviderErrorMatch(text)?.label === PROVIDER_NO_ENDPOINTS_LABEL;
105543
105608
  }
@@ -105609,7 +105674,7 @@ var import_semver = __toESM(require_semver2(), 1);
105609
105674
  // package.json
105610
105675
  var package_default = {
105611
105676
  name: "pullfrog",
105612
- version: "0.1.96",
105677
+ version: "0.1.98",
105613
105678
  cliContractVersion: "1",
105614
105679
  type: "module",
105615
105680
  bin: {
@@ -105637,7 +105702,7 @@ var package_default = {
105637
105702
  },
105638
105703
  devDependencies: {
105639
105704
  "@actions/core": "^1.11.1",
105640
- "@anthropic-ai/claude-code": "2.1.284",
105705
+ "@anthropic-ai/claude-code": "2.1.293",
105641
105706
  "@ark/fs": "0.56.0",
105642
105707
  "@ark/util": "0.56.0",
105643
105708
  "@clack/prompts": "^1.2.0",
@@ -105906,6 +105971,7 @@ ${this.buffer}`;
105906
105971
  };
105907
105972
  async function spawn(options) {
105908
105973
  const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
105974
+ options.signal?.throwIfAborted();
105909
105975
  installSignalHandler();
105910
105976
  const startTime = performance3.now();
105911
105977
  const retain = options.retain ?? "tail";
@@ -105941,17 +106007,18 @@ async function spawn(options) {
105941
106007
  let isActivityTimedOut = false;
105942
106008
  let lastActivityTime = performance3.now();
105943
106009
  let killedAtIdleMs;
106010
+ const terminate = () => {
106011
+ if (sigkillEscalatorId) return;
106012
+ killSelf("SIGTERM");
106013
+ sigkillEscalatorId = setTimeout(() => killSelf("SIGKILL"), 5e3);
106014
+ };
105944
106015
  if (options.timeout) {
105945
106016
  timeoutId = setTimeout(() => {
105946
106017
  isTimedOut = true;
105947
- killSelf("SIGTERM");
105948
- sigkillEscalatorId = setTimeout(() => {
105949
- if (!child.killed) {
105950
- killSelf("SIGKILL");
105951
- }
105952
- }, 5e3);
106018
+ terminate();
105953
106019
  }, options.timeout);
105954
106020
  }
106021
+ options.signal?.addEventListener("abort", terminate, { once: true });
105955
106022
  if (activityTimeoutMs > 0) {
105956
106023
  log.debug(
105957
106024
  `spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms`
@@ -106016,6 +106083,11 @@ async function spawn(options) {
106016
106083
  if (timeoutId) clearTimeout(timeoutId);
106017
106084
  if (sigkillEscalatorId) clearTimeout(sigkillEscalatorId);
106018
106085
  if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
106086
+ options.signal?.removeEventListener("abort", terminate);
106087
+ if (options.signal?.aborted) {
106088
+ reject(options.signal.reason);
106089
+ return;
106090
+ }
106019
106091
  if (isTimedOut) {
106020
106092
  reject(
106021
106093
  new SpawnTimeoutError(`process timed out after ${options.timeout}ms`, SPAWN_TIMEOUT_CODE)
@@ -106054,6 +106126,7 @@ ${killMsg}` : killMsg;
106054
106126
  if (timeoutId) clearTimeout(timeoutId);
106055
106127
  if (sigkillEscalatorId) clearTimeout(sigkillEscalatorId);
106056
106128
  if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
106129
+ options.signal?.removeEventListener("abort", terminate);
106057
106130
  const errMsg = `[spawn] ${options.cmd}: ${error49.message}`;
106058
106131
  console.error(errMsg);
106059
106132
  const existingStderr = stderrBuffer?.toString() ?? "";
@@ -106295,17 +106368,16 @@ Report findings clearly with file:line references and quoted evidence where poss
106295
106368
  // modes.ts
106296
106369
  var PR_SUMMARY_FORMAT = `### Default format
106297
106370
 
106298
- The body has at most three parts, in this order:
106371
+ The body has at most two parts, in this order:
106299
106372
 
106300
106373
  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
106374
  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
106375
 
106304
106376
  **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
106377
 
106306
106378
  **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
106379
 
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.
106380
+ **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
106381
 
106310
106382
  **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
106383
 
@@ -106454,8 +106526,6 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
106454
106526
 
106455
106527
  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
106528
 
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
106529
  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
106530
 
106461
106531
  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 +106549,6 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
106479
106549
 
106480
106550
  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
106551
 
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
106552
  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
106553
 
106486
106554
  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 +106570,7 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
106502
106570
  \`approved: false\`. Body opens with \`> [!CAUTION]\\n> This PR introduces ...\`, followed by the PR summary. Include all inline comments via \`comments\`.
106503
106571
  - **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
106572
  \`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):
106573
+ - **minor suggestions only** (low-stakes findings):
106506
106574
  \`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
106575
  - **informational observations** (mergeable as-is, nothing actionable \u2014 e.g. prior feedback addressed cleanly, calling out something noteworthy without recommending a change):
106508
106576
  \`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 +106613,6 @@ ${PR_SUMMARY_FORMAT}`
106545
106613
 
106546
106614
  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
106615
 
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
106616
  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
106617
 
106552
106618
  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 +106636,9 @@ ${PR_SUMMARY_FORMAT}`
106570
106636
 
106571
106637
  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
106638
 
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
106639
  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
106640
 
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.
106641
+ 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
106642
 
106579
106643
  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
106644
 
@@ -106585,7 +106649,7 @@ ${PR_SUMMARY_FORMAT}`
106585
106649
  - 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
106650
  - 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
106651
  - 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.
106652
+ - 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
106653
  - 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
106654
  - 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
106655
 
@@ -107802,7 +107866,7 @@ import { performance as performance6 } from "node:perf_hooks";
107802
107866
 
107803
107867
  // utils/codexHome.ts
107804
107868
  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";
107869
+ import { existsSync as existsSync4, mkdirSync as mkdirSync5, mkdtempSync, readFileSync as readFileSync2, writeFileSync as writeFileSync4 } from "node:fs";
107806
107870
  import { homedir as homedir2, userInfo } from "node:os";
107807
107871
  import { join as join5 } from "node:path";
107808
107872
 
@@ -107853,205 +107917,6 @@ function stringifyCodexAuthBody(body) {
107853
107917
  `;
107854
107918
  }
107855
107919
 
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
107920
  // utils/oauthWriteback.ts
108056
107921
  var core2 = __toESM(require_core(), 1);
108057
107922
 
@@ -110281,6 +110146,7 @@ async function apiFetch(options) {
110281
110146
 
110282
110147
  // utils/oauthWriteback.ts
110283
110148
  var OAUTH_WRITEBACK_STATE = "oauth_writeback";
110149
+ var SUBSCRIPTION_DATA_DIR_STATE = "subscription_data_dir";
110284
110150
 
110285
110151
  // utils/secretCommands.ts
110286
110152
  var core3 = __toESM(require_core(), 1);
@@ -110291,6 +110157,212 @@ function saveSecretState(name, value2) {
110291
110157
  if (isGitHubActions) core3.saveState(name, value2);
110292
110158
  }
110293
110159
 
110160
+ // utils/xaiOAuth.ts
110161
+ function parseXaiAuthBody(raw2) {
110162
+ let parsed2;
110163
+ try {
110164
+ parsed2 = JSON.parse(raw2);
110165
+ } catch {
110166
+ return null;
110167
+ }
110168
+ if (!parsed2 || typeof parsed2 !== "object") return null;
110169
+ const v = parsed2;
110170
+ if (v.auth_mode !== "grok") return null;
110171
+ const tokens = v.tokens;
110172
+ if (!tokens || typeof tokens !== "object") return null;
110173
+ const t = tokens;
110174
+ if (typeof t.access_token !== "string" || t.access_token.length === 0) return null;
110175
+ if (typeof t.refresh_token !== "string" || t.refresh_token.length === 0) return null;
110176
+ return {
110177
+ auth_mode: "grok",
110178
+ ...typeof v.refresh_rejected_at === "string" ? { refresh_rejected_at: v.refresh_rejected_at } : {},
110179
+ tokens: { access_token: t.access_token, refresh_token: t.refresh_token },
110180
+ ...typeof v.last_refresh === "string" ? { last_refresh: v.last_refresh } : {}
110181
+ };
110182
+ }
110183
+
110184
+ // utils/codexHome.ts
110185
+ var CODEX_AUTH_ENV = "CODEX_AUTH_JSON";
110186
+ var XAI_AUTH_ENV = "GROK_AUTH_JSON";
110187
+ var installedSubscriptionPaths = /* @__PURE__ */ new Map();
110188
+ var installedDataHome;
110189
+ function getSubscriptionDataHome() {
110190
+ return installedDataHome;
110191
+ }
110192
+ function clearInstalledSubscription(name) {
110193
+ const path4 = installedSubscriptionPaths.get(name);
110194
+ if (!path4 || !existsSync4(path4)) return;
110195
+ const value2 = JSON.parse(readFileSync2(path4, "utf8"));
110196
+ if (!value2 || typeof value2 !== "object") return;
110197
+ if (name === "CODEX_AUTH_JSON" && "openai" in value2) delete value2.openai;
110198
+ if (name === "GROK_AUTH_JSON" && "xai" in value2) delete value2.xai;
110199
+ writeFileSync4(path4, `${JSON.stringify(value2)}
110200
+ `, { mode: 384 });
110201
+ installedSubscriptionPaths.delete(name);
110202
+ }
110203
+ var PULLFROG_DATA_DIR = "/var/lib/pullfrog";
110204
+ function isRejectedChain(body) {
110205
+ if (!body.refresh_rejected_at) return false;
110206
+ log.warning(
110207
+ `\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.`
110208
+ );
110209
+ return true;
110210
+ }
110211
+ function installCodexAuth() {
110212
+ const raw2 = process.env[CODEX_AUTH_ENV];
110213
+ if (!raw2) return null;
110214
+ const body = parseCodexAuthBody(raw2);
110215
+ if (!body) {
110216
+ log.warning(`\xBB ${CODEX_AUTH_ENV} present but malformed; ignoring`);
110217
+ return null;
110218
+ }
110219
+ if (isRejectedChain(body)) return null;
110220
+ const expiresMs = decodeJwtExpMs(body.tokens.access_token) ?? 0;
110221
+ const xdgDataHome = resolveDataHome();
110222
+ if (!xdgDataHome) return null;
110223
+ const opencodeDir = join5(xdgDataHome, "opencode");
110224
+ const authPath = join5(opencodeDir, "auth.json");
110225
+ writeOpenCodeAuthEntry({
110226
+ opencodeDir,
110227
+ authPath,
110228
+ provider: "openai",
110229
+ entry: {
110230
+ type: "oauth",
110231
+ refresh: body.tokens.refresh_token,
110232
+ access: body.tokens.access_token,
110233
+ expires: expiresMs,
110234
+ ...body.tokens.account_id ? { accountId: body.tokens.account_id } : {}
110235
+ }
110236
+ });
110237
+ installedDataHome = xdgDataHome;
110238
+ log.info(`\xBB installed Codex auth at ${authPath}`);
110239
+ installedSubscriptionPaths.set(CODEX_AUTH_ENV, authPath);
110240
+ return {
110241
+ authPath,
110242
+ originalRefresh: body.tokens.refresh_token,
110243
+ originalIdToken: body.tokens.id_token
110244
+ };
110245
+ }
110246
+ function writeOpenCodeAuthEntry(params) {
110247
+ let existing = {};
110248
+ if (existsSync4(params.authPath)) {
110249
+ try {
110250
+ const parsed2 = JSON.parse(readFileSync2(params.authPath, "utf8"));
110251
+ if (parsed2 && typeof parsed2 === "object") existing = parsed2;
110252
+ } catch {
110253
+ log.warning(`\xBB ${params.authPath} was unreadable; rewriting it`);
110254
+ }
110255
+ }
110256
+ const merged = { ...existing, [params.provider]: params.entry };
110257
+ mkdirSync5(params.opencodeDir, { recursive: true });
110258
+ writeFileSync4(params.authPath, `${JSON.stringify(merged, null, 2)}
110259
+ `, { mode: 384 });
110260
+ }
110261
+ function installXaiAuth() {
110262
+ const raw2 = process.env[XAI_AUTH_ENV];
110263
+ if (!raw2) return null;
110264
+ const body = parseXaiAuthBody(raw2);
110265
+ if (!body) {
110266
+ log.warning(`\xBB ${XAI_AUTH_ENV} present but malformed; ignoring`);
110267
+ return null;
110268
+ }
110269
+ if (isXaiRejectedChain(body)) return null;
110270
+ const xdgDataHome = resolveDataHome();
110271
+ if (!xdgDataHome) return null;
110272
+ const opencodeDir = join5(xdgDataHome, "opencode");
110273
+ const authPath = join5(opencodeDir, "auth.json");
110274
+ writeOpenCodeAuthEntry({
110275
+ opencodeDir,
110276
+ authPath,
110277
+ provider: "xai",
110278
+ // opencode refreshes when `expires` is inside its skew window, and falls
110279
+ // back to the JWT's own exp when the stored value is absent. Decoding it
110280
+ // here means the first request uses the token we already hold instead of
110281
+ // burning a rotation on startup.
110282
+ entry: {
110283
+ type: "oauth",
110284
+ refresh: body.tokens.refresh_token,
110285
+ access: body.tokens.access_token,
110286
+ expires: decodeJwtExpMs(body.tokens.access_token) ?? 0
110287
+ }
110288
+ });
110289
+ installedDataHome = xdgDataHome;
110290
+ log.info(`\xBB installed Grok auth at ${authPath}`);
110291
+ installedSubscriptionPaths.set(XAI_AUTH_ENV, authPath);
110292
+ return { authPath, originalRefresh: body.tokens.refresh_token };
110293
+ }
110294
+ function isXaiRejectedChain(body) {
110295
+ if (!body.refresh_rejected_at) return false;
110296
+ log.warning(
110297
+ `\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.`
110298
+ );
110299
+ return true;
110300
+ }
110301
+ function installCodexHome() {
110302
+ const raw2 = process.env[CODEX_AUTH_ENV];
110303
+ if (!raw2) return null;
110304
+ const body = parseCodexAuthBody(raw2);
110305
+ if (!body) {
110306
+ log.warning(`\xBB ${CODEX_AUTH_ENV} present but malformed; ignoring`);
110307
+ return null;
110308
+ }
110309
+ if (isRejectedChain(body)) return null;
110310
+ if (!body.tokens.id_token) {
110311
+ log.warning(
110312
+ `\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.`
110313
+ );
110314
+ return null;
110315
+ }
110316
+ const root = resolveDataHome();
110317
+ if (!root) return null;
110318
+ const codexHome = join5(root, "codex");
110319
+ const authPath = join5(codexHome, "auth.json");
110320
+ mkdirSync5(codexHome, { recursive: true });
110321
+ writeFileSync4(authPath, stringifyCodexAuthBody(body), { mode: 384 });
110322
+ log.info(`\xBB installed Codex auth at ${authPath}`);
110323
+ return { codexHome, authPath, originalRefresh: body.tokens.refresh_token };
110324
+ }
110325
+ var dataHome;
110326
+ var unhostedSubscriptions = /* @__PURE__ */ new Set();
110327
+ function canInstallSubscription(name) {
110328
+ return name !== CODEX_AUTH_ENV && name !== XAI_AUTH_ENV || resolveDataHome() !== null;
110329
+ }
110330
+ function resolveDataHome() {
110331
+ if (process.env.CI !== "true") return join5(homedir2(), ".local", "share");
110332
+ if (dataHome === void 0) dataHome = bootstrapPullfrogDataDir();
110333
+ return dataHome;
110334
+ }
110335
+ function bootstrapPullfrogDataDir() {
110336
+ const user = userInfo().username;
110337
+ let primaryGroup;
110338
+ try {
110339
+ primaryGroup = execFileSync3("id", ["-gn", user], { stdio: "pipe", encoding: "utf-8" }).trim();
110340
+ } catch {
110341
+ primaryGroup = user;
110342
+ }
110343
+ try {
110344
+ execFileSync3("sudo", ["-n", "mkdir", "-p", PULLFROG_DATA_DIR], { stdio: "pipe" });
110345
+ execFileSync3("sudo", ["-n", "chown", `${user}:${primaryGroup}`, PULLFROG_DATA_DIR], {
110346
+ stdio: "pipe"
110347
+ });
110348
+ execFileSync3("sudo", ["-n", "chmod", "700", PULLFROG_DATA_DIR], { stdio: "pipe" });
110349
+ } catch (err) {
110350
+ log.warning(
110351
+ `\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.`
110352
+ );
110353
+ for (const name of [CODEX_AUTH_ENV, XAI_AUTH_ENV]) {
110354
+ if (process.env[name]) unhostedSubscriptions.add(name);
110355
+ delete process.env[name];
110356
+ }
110357
+ return null;
110358
+ }
110359
+ const runDir = mkdtempSync(join5(PULLFROG_DATA_DIR, "run-"));
110360
+ if (process.env.RUNNER_ENVIRONMENT !== "github-hosted") {
110361
+ saveSecretState(SUBSCRIPTION_DATA_DIR_STATE, runDir);
110362
+ }
110363
+ return runDir;
110364
+ }
110365
+
110294
110366
  // utils/secrets.ts
110295
110367
  var SENSITIVE_PATTERNS = [
110296
110368
  /_KEY$/i,
@@ -110937,9 +111009,8 @@ var codex = agent({
110937
111009
  initialUsage: initial.usage,
110938
111010
  reflectionPrompt: buildReflectionPrompt(ctx.toolState),
110939
111011
  // 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.
111012
+ // to continue, and `resume --last` could pick another run's session off CI,
111013
+ // where CODEX_HOME is the shared ~/.local/share/codex (in CI it is per run).
110943
111014
  canResume: (r) => r.threadId !== void 0,
110944
111015
  resume: async (c) => runCodex({
110945
111016
  ...runnerArgs,
@@ -110966,7 +111037,7 @@ var codex = agent({
110966
111037
  import { spawn as nodeSpawn2 } from "node:child_process";
110967
111038
  import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync6 } from "node:fs";
110968
111039
  import { totalmem } from "node:os";
110969
- import { join as join7 } from "node:path";
111040
+ import { join as join8 } from "node:path";
110970
111041
  import { performance as performance7 } from "node:perf_hooks";
110971
111042
 
110972
111043
  // node_modules/.pnpm/@opencode-ai+sdk@1.18.29/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js
@@ -116375,200 +116446,27 @@ var import_cross_spawn = __toESM(require_cross_spawn(), 1);
116375
116446
  // agents/opencode.ts
116376
116447
  var import_undici = __toESM(require_undici2(), 1);
116377
116448
 
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
116449
  // utils/openCodeModels.ts
116562
116450
  import { spawnSync as spawnSync3 } from "node:child_process";
116451
+ import { join as join7 } from "node:path";
116563
116452
  var baseline;
116564
116453
  var authorized;
116565
116454
  var failure;
116566
116455
  var ANSI_PATTERN = /\[[0-9;]*m/g;
116567
- function readModels(cliPath) {
116568
- const result = spawnSync3(cliPath, ["models"], {
116456
+ function openCodeHomeEnv(tmpdir3) {
116457
+ return {
116458
+ HOME: tmpdir3,
116459
+ XDG_CONFIG_HOME: join7(tmpdir3, ".config"),
116460
+ XDG_DATA_HOME: getSubscriptionDataHome() ?? join7(tmpdir3, ".local", "share"),
116461
+ XDG_CACHE_HOME: join7(tmpdir3, ".cache"),
116462
+ XDG_STATE_HOME: join7(tmpdir3, ".local", "state")
116463
+ };
116464
+ }
116465
+ function readModels(params) {
116466
+ const result = spawnSync3(params.cliPath, ["models"], {
116569
116467
  encoding: "utf-8",
116570
116468
  timeout: 3e4,
116571
- env: process.env,
116469
+ env: { ...process.env, ...openCodeHomeEnv(params.tmpdir) },
116572
116470
  stdio: ["ignore", "pipe", "pipe"]
116573
116471
  });
116574
116472
  if (result.status !== 0) {
@@ -116585,12 +116483,12 @@ function readModels(cliPath) {
116585
116483
  function getModelsFailure() {
116586
116484
  return failure;
116587
116485
  }
116588
- function captureBaselineModels(cliPath) {
116589
- baseline = readModels(cliPath);
116486
+ function captureBaselineModels(params) {
116487
+ baseline = readModels(params);
116590
116488
  log.debug(`\xBB opencode baseline: ${baseline.size} models`);
116591
116489
  }
116592
- function captureAuthorizedModels(cliPath) {
116593
- authorized = readModels(cliPath);
116490
+ function captureAuthorizedModels(params) {
116491
+ authorized = readModels(params);
116594
116492
  const base = baseline;
116595
116493
  if (base) {
116596
116494
  const diff = [...authorized].filter((m) => !base.has(m));
@@ -116610,6 +116508,190 @@ function getAuthorizedModels() {
116610
116508
  return authorized;
116611
116509
  }
116612
116510
 
116511
+ // utils/worktree.ts
116512
+ async function dirtyTrackedPaths(params = {}) {
116513
+ const result = await spawn({
116514
+ cmd: "git",
116515
+ args: ["diff", "--name-only", "HEAD"],
116516
+ env: process.env,
116517
+ cwd: params.cwd ?? process.cwd(),
116518
+ activityTimeout: 0
116519
+ });
116520
+ if (result.exitCode !== 0) {
116521
+ throw new Error(
116522
+ `git diff --name-only HEAD failed (exit ${result.exitCode}): ${result.stderr.trim() || "(no stderr)"}`
116523
+ );
116524
+ }
116525
+ return new Set(result.stdout.split("\n").filter(Boolean));
116526
+ }
116527
+ async function restoreDirtiedSince(params) {
116528
+ const dirtied = [...await dirtyTrackedPaths({ cwd: params.cwd ?? process.cwd() })].filter(
116529
+ (path4) => !params.before.has(path4)
116530
+ );
116531
+ if (dirtied.length === 0) return;
116532
+ const result = await spawn({
116533
+ cmd: "git",
116534
+ args: ["restore", "--staged", "--worktree", "--", ...dirtied],
116535
+ env: process.env,
116536
+ cwd: params.cwd ?? process.cwd(),
116537
+ activityTimeout: 0
116538
+ });
116539
+ if (result.exitCode !== 0) {
116540
+ log.warning(
116541
+ `\xBB failed to restore ${dirtied.length} tracked file(s) modified by ${params.actor}: ${result.stderr.trim() || "(no stderr)"}`
116542
+ );
116543
+ return;
116544
+ }
116545
+ log.info(
116546
+ `\xBB restored ${dirtied.length} tracked file(s) modified by ${params.actor}: ${dirtied.join(", ")}`
116547
+ );
116548
+ }
116549
+
116550
+ // agents/opencodePlugin.ts
116551
+ var PULLFROG_BUS_EVENT_TYPE = "pullfrog_bus_event";
116552
+ var PULLFROG_OPENCODE_GATE_PLUGIN_FILENAME = "pullfrog-subagent-gate.ts";
116553
+ var PULLFROG_OPENCODE_PLUGIN_SOURCE = `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
116554
+ // surfaces opencode subagent activity that the CLI's run-loop discards. see
116555
+ // action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
116556
+ // inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
116557
+ // the user's working tree. the subagent gate ships as a separate plugin
116558
+ // (pullfrog-subagent-gate.ts).
116559
+
116560
+ const PULLFROG_BUS_EVENT_TYPE = ${JSON.stringify(PULLFROG_BUS_EVENT_TYPE)};
116561
+
116562
+ // orchestratorSessionID locks to the first sessionID we observe on the bus
116563
+ // (\`message.part.updated\`). opencode's run command creates exactly one
116564
+ // top-level session before any subagent is dispatched, and the orchestrator
116565
+ // emits at least one event before any subagent does \u2014 so first-seen-wins
116566
+ // captures the orchestrator; every subagent part has a non-matching
116567
+ // sessionID.
116568
+ let orchestratorSessionID: string | undefined;
116569
+
116570
+ function isOrchestratorTaskDispatch(part: {
116571
+ type?: string;
116572
+ tool?: string;
116573
+ state?: { status?: string };
116574
+ } | undefined): boolean {
116575
+ if (!part || part.type !== "tool") return false;
116576
+ if (part.tool !== "task") return false;
116577
+ // only forward at status="running" (not "pending"). at pending the
116578
+ // state.input is still {} \u2014 the orchestrator has emitted the part shell
116579
+ // but the LLM hasn't filled in description/subagent_type/prompt yet. by
116580
+ // running, input is populated and recordTaskDispatch can derive the lens
116581
+ // label correctly.
116582
+ return part.state?.status === "running";
116583
+ }
116584
+
116585
+ export default async function pullfrogEventsPlugin() {
116586
+ return {
116587
+ event: async (input: {
116588
+ event: {
116589
+ type: string;
116590
+ properties?: {
116591
+ part?: {
116592
+ sessionID?: string;
116593
+ type?: string;
116594
+ tool?: string;
116595
+ state?: { status?: string };
116596
+ };
116597
+ };
116598
+ };
116599
+ }) => {
116600
+ const event = input?.event;
116601
+ if (!event || typeof event !== "object") return;
116602
+ if (event.type !== "message.part.updated") return;
116603
+ const part = event.properties?.part;
116604
+ const sessionID = part?.sessionID;
116605
+ if (typeof sessionID !== "string" || sessionID.length === 0) return;
116606
+ if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
116607
+
116608
+ if (sessionID === orchestratorSessionID) {
116609
+ // skip orchestrator events EXCEPT early task dispatches.
116610
+ if (!isOrchestratorTaskDispatch(part)) return;
116611
+ }
116612
+
116613
+ try {
116614
+ const line = JSON.stringify({
116615
+ type: PULLFROG_BUS_EVENT_TYPE,
116616
+ bus_event: event,
116617
+ });
116618
+ process.stdout.write(line + "\\n");
116619
+ } catch {
116620
+ // a circular reference or BigInt etc. would throw; swallow rather
116621
+ // than letting a single bad event take down the plugin.
116622
+ }
116623
+ },
116624
+ };
116625
+ }
116626
+ `;
116627
+ function buildOpencodeSubagentGateSource(ctx) {
116628
+ 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.`;
116629
+ return `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
116630
+ // hard-blocks state-mutating MCP tool calls from opencode subagents (PR
116631
+ // following pullfrog/app#796 \u2014 see action/agents/subagentToolGates.ts). see
116632
+ // action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
116633
+ // inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
116634
+ // the user's working tree.
116635
+
116636
+ // embedded at template-render time so the plugin source has no imports. the
116637
+ // denied set is derived from each tool's \`mutates\` flag \u2014 see
116638
+ // action/agents/subagentToolGates.ts.
116639
+ const SUBAGENT_DENIED_TOOLS = new Set(${JSON.stringify(ctx.subagentDeniedTools)});
116640
+
116641
+ // opencode presents MCP tools as \`pullfrog_<bare>\`; strip that to compare
116642
+ // against the canonical bare names in SUBAGENT_DENIED_TOOLS. native tools
116643
+ // (read, write, bash, task, etc.) lack the prefix and never match the deny
116644
+ // list anyway.
116645
+ function stripPullfrogPrefix(toolName: string): string {
116646
+ if (toolName.startsWith("pullfrog_")) return toolName.slice("pullfrog_".length);
116647
+ return toolName;
116648
+ }
116649
+
116650
+ // orchestratorSessionID locks to the first sessionID we observe on
116651
+ // \`tool.execute.before\`. the orchestrator must invoke a tool (e.g. its
116652
+ // \`task\` dispatch) before any subagent session exists, so first-seen-wins
116653
+ // always captures the orchestrator; every subagent call therefore has a
116654
+ // non-matching sessionID and is subject to the deny check.
116655
+ let orchestratorSessionID: string | undefined;
116656
+
116657
+ export default async function pullfrogSubagentGatePlugin() {
116658
+ return {
116659
+ "tool.execute.before": async (
116660
+ input: { tool?: string; sessionID?: string; callID?: string },
116661
+ _output: { args?: Record<string, unknown> }
116662
+ ) => {
116663
+ // input: { tool, sessionID, callID } \u2014 confirmed against
116664
+ // anomalyco/opencode packages/opencode/src/session/tools.ts which
116665
+ // calls plugin.trigger("tool.execute.before", { tool, sessionID,
116666
+ // callID }, { args }). throwing here surfaces as a tool error to the
116667
+ // model (Effect's run.promise propagates the rejection through the
116668
+ // AI SDK's tool-runtime), so the subagent sees a denial and can
116669
+ // pick a different action.
116670
+ const sessionID = typeof input?.sessionID === "string" ? input.sessionID : "";
116671
+ const tool = typeof input?.tool === "string" ? input.tool : "";
116672
+ // above the sessionID check: the bash block must never depend on a
116673
+ // session id being present.
116674
+ if (tool === "bash") {
116675
+ throw new Error(${JSON.stringify(bashRefusal)});
116676
+ }
116677
+ if (!sessionID || !tool) return;
116678
+ if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
116679
+ if (sessionID === orchestratorSessionID) return;
116680
+ const bare = stripPullfrogPrefix(tool);
116681
+ if (!SUBAGENT_DENIED_TOOLS.has(bare)) return;
116682
+ throw new Error(
116683
+ "subagent attempted to call denied tool '" + bare + "'. " +
116684
+ "subagents share the orchestrator's in-process working tree and toolState; " +
116685
+ "state-changing MCP tools (checkout_pr, push_branch, create_pull_request_review, " +
116686
+ "report_progress, etc.) are reserved for the orchestrator. " +
116687
+ "report findings back to the orchestrator and let it perform the mutation."
116688
+ );
116689
+ },
116690
+ };
116691
+ }
116692
+ `;
116693
+ }
116694
+
116613
116695
  // agents/subagentModels.ts
116614
116696
  function deriveSubagentModels(orchestratorSpec) {
116615
116697
  if (!orchestratorSpec) return { reviewer: void 0 };
@@ -116984,6 +117066,7 @@ async function dispatchEvent(ctx, event) {
116984
117066
  const err = event.properties.error;
116985
117067
  const message = err ? extractErrorMessage(err) : "(no error payload)";
116986
117068
  log.info(`\xBB ${ctx.label} session error: ${message}`);
117069
+ if (err) ctx.lastSessionError = message;
116987
117070
  return;
116988
117071
  }
116989
117072
  }
@@ -117122,6 +117205,7 @@ async function runPromptTurn(ctx, params) {
117122
117205
  ctx.promptMessageID = void 0;
117123
117206
  ctx.diagnostic.sawModelOutput = false;
117124
117207
  ctx.diagnostic.idleSec = void 0;
117208
+ ctx.lastSessionError = void 0;
117125
117209
  let assistant;
117126
117210
  let returnedParts;
117127
117211
  let networkError = null;
@@ -117166,11 +117250,12 @@ async function runPromptTurn(ctx, params) {
117166
117250
  }
117167
117251
  const cause = ctx.serverExitCause() ?? ctx.diagnostic.lastProviderError;
117168
117252
  const diagnosis = cause ? ` \u2014 likely cause: ${cause}` : "";
117253
+ const promptCause = ctx.serverExitCause() ?? (ctx.diagnostic.sawModelOutput ? void 0 : ctx.lastSessionError) ?? ctx.diagnostic.lastProviderError;
117169
117254
  if (networkError) {
117170
117255
  return {
117171
117256
  success: false,
117172
117257
  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}`,
117258
+ 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
117259
  usage
117175
117260
  };
117176
117261
  }
@@ -117362,15 +117447,14 @@ var opencode = agent({
117362
117447
  const isBedrockRoute = rawModel !== void 0 && bedrockModelId !== void 0 && bedrockModelId === rawModel;
117363
117448
  const vertexModel = resolveVertexOpenCodeModel(rawModel);
117364
117449
  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");
117450
+ const codexAuth = installCodexAuth();
117451
+ const xaiAuth = installXaiAuth();
117452
+ const homeEnv = openCodeHomeEnv(ctx.tmpdir);
117453
+ const opencodePluginDir = join8(homeEnv.XDG_CONFIG_HOME, "opencode", "plugin");
117370
117454
  mkdirSync7(opencodePluginDir, { recursive: true });
117371
117455
  writeFileSync6(
117372
- join7(opencodePluginDir, PULLFROG_OPENCODE_GATE_PLUGIN_FILENAME),
117373
- buildOpencodeSubagentGateSource(ctx.subagentDeniedTools)
117456
+ join8(opencodePluginDir, PULLFROG_OPENCODE_GATE_PLUGIN_FILENAME),
117457
+ buildOpencodeSubagentGateSource(ctx)
117374
117458
  );
117375
117459
  const agentBrowserVersion = getDevDependencyVersion("agent-browser");
117376
117460
  addSkill({
@@ -117380,8 +117464,6 @@ var opencode = agent({
117380
117464
  agent: "opencode"
117381
117465
  });
117382
117466
  installBundledSkills({ home: homeEnv.HOME });
117383
- const codexAuth = installCodexAuth();
117384
- const xaiAuth = installXaiAuth();
117385
117467
  const permissionOverride = JSON.stringify({
117386
117468
  external_directory: { "*": "deny", "/tmp/*": "allow" },
117387
117469
  read: { "*": "allow", ...GIT_NATIVE_READ_DENY_OPENCODE },
@@ -117400,7 +117482,6 @@ var opencode = agent({
117400
117482
  delete env2.OPENCODE_EXPERIMENTAL_CODE_MODE;
117401
117483
  const writebacks = [];
117402
117484
  if (codexAuth) {
117403
- env2.XDG_DATA_HOME = codexAuth.xdgDataHome;
117404
117485
  delete env2.OPENAI_API_KEY;
117405
117486
  writebacks.push({
117406
117487
  secretName: "CODEX_AUTH_JSON",
@@ -117411,7 +117492,6 @@ var opencode = agent({
117411
117492
  });
117412
117493
  }
117413
117494
  if (xaiAuth) {
117414
- env2.XDG_DATA_HOME = xaiAuth.xdgDataHome;
117415
117495
  delete env2.XAI_API_KEY;
117416
117496
  writebacks.push({
117417
117497
  secretName: "GROK_AUTH_JSON",
@@ -117484,6 +117564,7 @@ var opencode = agent({
117484
117564
  mcpToolCalls: 0,
117485
117565
  recentStderr: server.recentStderr,
117486
117566
  serverExitCause: server.exitCause,
117567
+ lastSessionError: void 0,
117487
117568
  diagnostic: {
117488
117569
  label: "Pullfrog",
117489
117570
  recentStderr: server.recentStderr,
@@ -172402,7 +172483,7 @@ function closeBrowserDaemon(toolState) {
172402
172483
  // mcp/checkout.ts
172403
172484
  import { createHash as createHash2 } from "node:crypto";
172404
172485
  import { statSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync10 } from "node:fs";
172405
- import { join as join15 } from "node:path";
172486
+ import { join as join16 } from "node:path";
172406
172487
 
172407
172488
  // node_modules/.pnpm/@ark+schema@0.56.0/node_modules/@ark/schema/out/shared/errors.js
172408
172489
  var ArkError = class _ArkError extends CastableBase {
@@ -180773,13 +180854,14 @@ function ensureRepoState(toolState, init) {
180773
180854
  // utils/changeImpact.ts
180774
180855
  import { spawn as spawn2 } from "node:child_process";
180775
180856
  import { writeFileSync as writeFileSync7 } from "node:fs";
180776
- import { join as join8 } from "node:path";
180857
+ import { join as join9 } from "node:path";
180777
180858
  var MAX_CANDIDATES = 24;
180778
180859
  var MAX_ATOM_LENGTH = 128;
180779
180860
  var MAX_ATOMS = 12;
180780
180861
  var MAX_REFERENCES = 6;
180781
180862
  var MAX_EXCERPT = 160;
180782
180863
  var MAX_BYTES = 16e3;
180864
+ var LOOKUP_BUDGET_MS = 6e4;
180783
180865
  function isSymbolShaped(atom) {
180784
180866
  return atom.length >= 4 && atom.length <= MAX_ATOM_LENGTH && /[A-Z_]/.test(atom);
180785
180867
  }
@@ -180982,7 +181064,7 @@ function consumeGrepChunk(params) {
180982
181064
  async function lookupReferences(params) {
180983
181065
  const buckets = emptyBuckets(params.candidates);
180984
181066
  if (params.candidates.length === 0) return { buckets, error: void 0 };
180985
- const patterns = params.candidates.flatMap((candidate) => ["-e", candidate.atom]);
181067
+ const pattern = params.candidates.map((candidate) => candidate.atom).join("|");
180986
181068
  const child = spawn2(
180987
181069
  "git",
180988
181070
  [
@@ -180993,13 +181075,14 @@ async function lookupReferences(params) {
180993
181075
  "-z",
180994
181076
  "-I",
180995
181077
  "-w",
180996
- "-F",
180997
- ...patterns,
181078
+ "-E",
181079
+ "-e",
181080
+ pattern,
180998
181081
  params.treeish,
180999
181082
  "--",
181000
181083
  ":(top)"
181001
181084
  ],
181002
- { env: resolveEnv(void 0), stdio: ["ignore", "pipe", "pipe"] }
181085
+ { env: resolveEnv(void 0), stdio: ["ignore", "pipe", "pipe"], signal: params.signal }
181003
181086
  );
181004
181087
  const state = createGrepRecordState();
181005
181088
  const maxAtomLength = Math.max(...params.candidates.map((candidate) => candidate.atom.length));
@@ -181019,7 +181102,7 @@ async function lookupReferences(params) {
181019
181102
  child.stderr.resume();
181020
181103
  const status = await new Promise((resolve3) => {
181021
181104
  child.once("error", (error49) => {
181022
- spawnError = error49.message;
181105
+ spawnError = error49.cause instanceof Error ? error49.cause.message : error49.message;
181023
181106
  resolve3(null);
181024
181107
  });
181025
181108
  child.once("close", resolve3);
@@ -181107,8 +181190,10 @@ async function createChangeImpactArtifact(ctx, params) {
181107
181190
  const lookup2 = await lookupReferences({
181108
181191
  candidates: candidates.selected,
181109
181192
  addedLines: candidates.addedLines,
181110
- treeish: params.headSha
181193
+ treeish: params.headSha,
181194
+ signal: AbortSignal.any([params.signal, AbortSignal.timeout(LOOKUP_BUDGET_MS)])
181111
181195
  });
181196
+ params.signal.throwIfAborted();
181112
181197
  const artifact = formatArtifact({
181113
181198
  entries: candidates.selected.map((change) => {
181114
181199
  return { change, bucket: lookup2.buckets.get(change.atom) ?? { count: 0, samples: [] } };
@@ -181120,7 +181205,7 @@ async function createChangeImpactArtifact(ctx, params) {
181120
181205
  headSha: params.headSha,
181121
181206
  lookupError: lookup2.error
181122
181207
  });
181123
- const path4 = join8(ctx.tmpdir, `pr-${params.pullNumber}-${params.headSha.slice(0, 7)}-impact.md`);
181208
+ const path4 = join9(ctx.tmpdir, `pr-${params.pullNumber}-${params.headSha.slice(0, 7)}-impact.md`);
181124
181209
  writeFileSync7(path4, artifact.content);
181125
181210
  return {
181126
181211
  path: path4,
@@ -181374,7 +181459,7 @@ function readNumber(params) {
181374
181459
  import { execSync } from "node:child_process";
181375
181460
  import { createHash } from "node:crypto";
181376
181461
  import { readFileSync as readFileSync4, realpathSync, unlinkSync } from "node:fs";
181377
- import { join as join9 } from "node:path";
181462
+ import { join as join10 } from "node:path";
181378
181463
 
181379
181464
  // utils/shell.ts
181380
181465
  import { spawnSync as spawnSync5 } from "node:child_process";
@@ -181464,7 +181549,7 @@ function resolveHooksDir(cwd, gitPath) {
181464
181549
  cwd,
181465
181550
  log: false
181466
181551
  }).trim();
181467
- const hooksDir = join9(commonDir, "hooks");
181552
+ const hooksDir = join10(commonDir, "hooks");
181468
181553
  hooksDirCache.set(cwd, hooksDir);
181469
181554
  return hooksDir;
181470
181555
  }
@@ -181511,7 +181596,8 @@ async function $git(subcommand, args2, options) {
181511
181596
  GIT_CONFIG_COUNT: "0",
181512
181597
  GIT_CONFIG_PARAMETERS: ""
181513
181598
  },
181514
- activityTimeout: 0
181599
+ activityTimeout: 0,
181600
+ signal: options.signal
181515
181601
  });
181516
181602
  if (result.stderr.includes("askpass-compromised")) {
181517
181603
  log.info("askpass code was replayed after revoke \u2014 token has been revoked");
@@ -181578,9 +181664,10 @@ import { spawnSync as spawnSync7 } from "node:child_process";
181578
181664
  // mcp/shell.ts
181579
181665
  import { spawn as spawn3, spawnSync as spawnSync6 } from "node:child_process";
181580
181666
  import { randomUUID as randomUUID3 } from "node:crypto";
181581
- import { closeSync, openSync, writeFileSync as writeFileSync8 } from "node:fs";
181667
+ import { once } from "node:events";
181668
+ import { closeSync, existsSync as existsSync7, openSync, writeFileSync as writeFileSync8 } from "node:fs";
181582
181669
  import { userInfo as userInfo2 } from "node:os";
181583
- import { join as join10 } from "node:path";
181670
+ import { join as join11 } from "node:path";
181584
181671
  import { setTimeout as sleep3 } from "node:timers/promises";
181585
181672
 
181586
181673
  // node_modules/.pnpm/@toon-format+toon@1.4.0/node_modules/@toon-format/toon/dist/index.mjs
@@ -182224,7 +182311,7 @@ function buildFsMounts(repoDir) {
182224
182311
  function shimMount() {
182225
182312
  const tempDir = process.env.PULLFROG_TEMP_DIR;
182226
182313
  if (!tempDir) return "";
182227
- const dir = join10(tempDir, "pm-bin").replace(/'/g, "'\\''");
182314
+ const dir = join11(tempDir, "pm-bin").replace(/'/g, "'\\''");
182228
182315
  return `[ -d '${dir}' ] && mount --bind '${dir}' '${dir}' 2>/dev/null && mount -o remount,bind,ro '${dir}' 2>/dev/null;`;
182229
182316
  }
182230
182317
  var _repoRoot;
@@ -182395,7 +182482,7 @@ ${spawnError}` : spawnError;
182395
182482
  }
182396
182483
  function capOutput(output) {
182397
182484
  if (output.length <= MAX_OUTPUT_CHARS) return output;
182398
- const fullPath = join10(getTempDir(), `shell-${randomUUID3().slice(0, 8)}.log`);
182485
+ const fullPath = join11(getTempDir(), `shell-${randomUUID3().slice(0, 8)}.log`);
182399
182486
  writeFileSync8(fullPath, output);
182400
182487
  const elided = output.length - MAX_OUTPUT_CHARS;
182401
182488
  return `... [${elided} chars truncated; output saved to ${fullPath}] ...
@@ -182432,6 +182519,7 @@ Do NOT use this tool for git commands \u2014 use the dedicated git tools instead
182432
182519
  }
182433
182520
  const timeout = Math.min(params.timeout ?? 3e4, 12e4);
182434
182521
  const cwd = params.working_directory ?? process.cwd();
182522
+ if (!existsSync7(cwd)) throw new Error(`working_directory does not exist: ${cwd}`);
182435
182523
  const env2 = resolveEnv(ctx.payload.shell === "enabled" ? "inherit" : "restricted");
182436
182524
  if (params.command.includes("agent-browser")) {
182437
182525
  const daemonError = ensureBrowserDaemon(ctx.toolState);
@@ -182450,8 +182538,8 @@ Do NOT use this tool for git commands \u2014 use the dedicated git tools instead
182450
182538
  if (params.background) {
182451
182539
  const tempDir = getTempDir();
182452
182540
  const handle = `bg-${randomUUID3().slice(0, 8)}`;
182453
- const outputPath = join10(tempDir, `${handle}.log`);
182454
- const pidPath = join10(tempDir, `${handle}.pid`);
182541
+ const outputPath = join11(tempDir, `${handle}.log`);
182542
+ const pidPath = join11(tempDir, `${handle}.pid`);
182455
182543
  const logFd = openSync(outputPath, "a");
182456
182544
  let proc;
182457
182545
  try {
@@ -182465,7 +182553,8 @@ Do NOT use this tool for git commands \u2014 use the dedicated git tools instead
182465
182553
  closeSync(logFd);
182466
182554
  }
182467
182555
  if (!proc.pid) {
182468
- throw new Error("failed to start background process");
182556
+ const [error49] = await once(proc, "error");
182557
+ throw new Error(`failed to start background process: ${error49.message}`);
182469
182558
  }
182470
182559
  proc.unref();
182471
182560
  writeFileSync8(pidPath, `${proc.pid}
@@ -182742,12 +182831,12 @@ function postProcessRangeDiff(raw2, contextLines = 3) {
182742
182831
  // mcp/git.ts
182743
182832
  import { randomUUID as randomUUID4 } from "node:crypto";
182744
182833
  import { writeFileSync as writeFileSync9 } from "node:fs";
182745
- import { join as join13 } from "node:path";
182834
+ import { join as join14 } from "node:path";
182746
182835
 
182747
182836
  // utils/apiCommit.ts
182748
182837
  import { execFileSync as execFileSync5 } from "node:child_process";
182749
182838
  import { lstat, readlink } from "node:fs/promises";
182750
- import { join as join11 } from "node:path";
182839
+ import { join as join12 } from "node:path";
182751
182840
  var GITHUB_API = "https://api.github.com";
182752
182841
  var MAX_BLOB_BYTES = 30 * 1024 * 1024;
182753
182842
  var BLOB_UPLOAD_CONCURRENCY = 8;
@@ -182812,7 +182901,7 @@ async function assertApiCommittable(files) {
182812
182901
  const root = getRepoRoot();
182813
182902
  const attrs = $(
182814
182903
  "git",
182815
- ["check-attr", "filter", "-z", "--", ...present.map((p) => join11(root, p))],
182904
+ ["check-attr", "filter", "-z", "--", ...present.map((p) => join12(root, p))],
182816
182905
  { log: false }
182817
182906
  );
182818
182907
  const parts = attrs.split("\0");
@@ -182824,7 +182913,7 @@ async function assertApiCommittable(files) {
182824
182913
  }
182825
182914
  }
182826
182915
  for (const path4 of present) {
182827
- const stat = await lstat(join11(root, path4));
182916
+ const stat = await lstat(join12(root, path4));
182828
182917
  if (stat.isDirectory()) {
182829
182918
  throw new Error(
182830
182919
  `'${path4}' is a directory (nested repository or submodule?) \u2014 signed commits only support files and symlinks.`
@@ -182833,7 +182922,7 @@ async function assertApiCommittable(files) {
182833
182922
  }
182834
182923
  }
182835
182924
  async function createBlobEntry(params) {
182836
- const absPath = join11(params.repoRoot, params.path);
182925
+ const absPath = join12(params.repoRoot, params.path);
182837
182926
  const stat = await lstat(absPath);
182838
182927
  if (stat.size > MAX_BLOB_BYTES) {
182839
182928
  throw new Error(
@@ -183013,7 +183102,7 @@ this usually means your local branch has commits that were never pushed \u2014 s
183013
183102
  var core4 = __toESM(require_core(), 1);
183014
183103
  import { createSign } from "node:crypto";
183015
183104
  import { rename, writeFile } from "node:fs/promises";
183016
- import { dirname as dirname3, join as join12 } from "node:path";
183105
+ import { dirname as dirname3, join as join13 } from "node:path";
183017
183106
 
183018
183107
  // node_modules/.pnpm/@octokit+plugin-throttling@11.0.5_@octokit+core@7.0.8/node_modules/@octokit/plugin-throttling/dist-bundle/index.js
183019
183108
  var import_light = __toESM(require_light(), 1);
@@ -187562,7 +187651,7 @@ function getGitHubUsageSummary() {
187562
187651
  }
187563
187652
  async function writeGitHubUsageSummaryToFile(path4) {
187564
187653
  const summary2 = getGitHubUsageSummary();
187565
- const tmpPath = join12(dirname3(path4), `.usage-summary-${process.pid}.tmp`);
187654
+ const tmpPath = join13(dirname3(path4), `.usage-summary-${process.pid}.tmp`);
187566
187655
  await writeFile(tmpPath, JSON.stringify(summary2));
187567
187656
  await rename(tmpPath, path4);
187568
187657
  }
@@ -188190,7 +188279,7 @@ function countAhead(head, base, cwd) {
188190
188279
  function spillGitOutput(params) {
188191
188280
  const tempDir = process.env.PULLFROG_TEMP_DIR;
188192
188281
  if (!tempDir) throw new Error("PULLFROG_TEMP_DIR not set");
188193
- const outputPath = join13(tempDir, `git-${params.command}-${randomUUID4().slice(0, 8)}.txt`);
188282
+ const outputPath = join14(tempDir, `git-${params.command}-${randomUUID4().slice(0, 8)}.txt`);
188194
188283
  writeFileSync9(outputPath, params.output);
188195
188284
  const previewByLines = params.output.split("\n").slice(0, OVERFLOW_PREVIEW_LINES).join("\n");
188196
188285
  const preview = previewByLines.length <= OVERFLOW_PREVIEW_MAX_CHARS ? previewByLines : `${previewByLines.slice(0, OVERFLOW_PREVIEW_MAX_CHARS)}\u2026`;
@@ -189845,11 +189934,11 @@ async function reportReviewNodeId(ctx, params) {
189845
189934
 
189846
189935
  // utils/setup.ts
189847
189936
  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";
189937
+ import { mkdtempSync as mkdtempSync2, readdirSync, realpathSync as realpathSync2, rmSync as rmSync2, unlinkSync as unlinkSync2 } from "node:fs";
189849
189938
  import { tmpdir as tmpdir2 } from "node:os";
189850
- import { join as join14 } from "node:path";
189939
+ import { join as join15 } from "node:path";
189851
189940
  function createTempDirectory() {
189852
- const path4 = mkdtempSync(join14(tmpdir2(), "pullfrog-"));
189941
+ const path4 = mkdtempSync2(join15(tmpdir2(), "pullfrog-"));
189853
189942
  process.env.PULLFROG_TEMP_DIR = path4;
189854
189943
  log.info(`\xBB created temp dir at ${path4}`);
189855
189944
  return { path: path4, [Symbol.dispose]: () => removeTempDirectory(path4) };
@@ -189909,13 +189998,13 @@ function wipeRunnerLeakSurface() {
189909
189998
  return [];
189910
189999
  }
189911
190000
  };
189912
- const fileCommandsDir = join14(runnerTemp, "_runner_file_commands");
190001
+ const fileCommandsDir = join15(runnerTemp, "_runner_file_commands");
189913
190002
  for (const entry of listDir(fileCommandsDir)) {
189914
- tryUnlink(join14(fileCommandsDir, entry));
190003
+ tryUnlink(join15(fileCommandsDir, entry));
189915
190004
  }
189916
190005
  for (const entry of listDir(runnerTemp)) {
189917
190006
  if (entry.endsWith(".sh") || /^git-credentials-.*\.config$/.test(entry)) {
189918
- tryUnlink(join14(runnerTemp, entry));
190007
+ tryUnlink(join15(runnerTemp, entry));
189919
190008
  }
189920
190009
  }
189921
190010
  if (wiped.length > 0) {
@@ -190187,7 +190276,7 @@ async function ensureBeforeShaReachable(params) {
190187
190276
  "origin",
190188
190277
  tempBranch
190189
190278
  ],
190190
- { token: params.gitToken, refreshGitToken: params.refreshGitToken },
190279
+ { token: params.gitToken, refreshGitToken: params.refreshGitToken, signal: params.signal },
190191
190280
  `before_sha temp branch ${tempBranch}`
190192
190281
  );
190193
190282
  log.debug(`\xBB fetched before_sha via temp branch ${tempBranch}`);
@@ -190228,7 +190317,7 @@ function cleanupStaleGitLocks() {
190228
190317
  } catch {
190229
190318
  continue;
190230
190319
  }
190231
- if (now - mtimeMs < STALE_LOCK_AGE_MS) continue;
190320
+ if (now - mtimeMs < STALE_LOCK_AGE_MS || mtimeMs >= performance.timeOrigin) continue;
190232
190321
  try {
190233
190322
  unlinkSync3(relPath);
190234
190323
  log.warning(`\xBB removed stale ${relPath} from prior run`);
@@ -190261,7 +190350,7 @@ async function abortIfPullRequestMoved(args2) {
190261
190350
  );
190262
190351
  }
190263
190352
  async function checkoutPrBranch(pr, params) {
190264
- const { octokit, owner, name, gitToken, refreshGitToken, toolState, beforeSha } = params;
190353
+ const { octokit, owner, name, gitToken, refreshGitToken, toolState, beforeSha, signal } = params;
190265
190354
  rejectIfLeadingDash(pr.baseRef, "PR base ref");
190266
190355
  rejectIfLeadingDash(pr.headRef, "PR head ref");
190267
190356
  const repoState = requireRepoState(toolState, owner, name);
@@ -190275,7 +190364,7 @@ async function checkoutPrBranch(pr, params) {
190275
190364
  log.debug(`\xBB fetching base branch (${pr.baseRef})...`);
190276
190365
  await $gitFetchWithDeepen(
190277
190366
  ["--no-tags", "origin", pr.baseRef],
190278
- { token: gitToken, refreshGitToken },
190367
+ { token: gitToken, refreshGitToken, signal },
190279
190368
  `base branch ${pr.baseRef}`
190280
190369
  );
190281
190370
  if (!alreadyOnBranch) {
@@ -190286,7 +190375,7 @@ async function checkoutPrBranch(pr, params) {
190286
190375
  try {
190287
190376
  await $gitFetchWithDeepen(
190288
190377
  ["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`],
190289
- { token: gitToken, refreshGitToken },
190378
+ { token: gitToken, refreshGitToken, signal },
190290
190379
  `PR #${pr.number}`
190291
190380
  );
190292
190381
  } catch (e) {
@@ -190311,7 +190400,8 @@ async function checkoutPrBranch(pr, params) {
190311
190400
  repo: name,
190312
190401
  gitToken,
190313
190402
  refreshGitToken,
190314
- isShallow
190403
+ isShallow,
190404
+ signal
190315
190405
  }) : false;
190316
190406
  if (isShallow) {
190317
190407
  let deepenDepth = 0;
@@ -190346,7 +190436,8 @@ async function checkoutPrBranch(pr, params) {
190346
190436
  if (deepenDepth) {
190347
190437
  log.debug(`\xBB deepening by ${deepenDepth} to reach merge base...`);
190348
190438
  await $git("fetch", [`--deepen=${deepenDepth}`, "--no-tags", "origin"], {
190349
- token: gitToken
190439
+ token: gitToken,
190440
+ signal
190350
190441
  });
190351
190442
  }
190352
190443
  }
@@ -190384,6 +190475,7 @@ async function checkoutPrBranch(pr, params) {
190384
190475
  remoteBranch: pr.headRef,
190385
190476
  localBranch
190386
190477
  };
190478
+ signal.throwIfAborted();
190387
190479
  const postCheckoutHook = await executeLifecycleHook({
190388
190480
  event: "post-checkout",
190389
190481
  script: params.postCheckoutScript,
@@ -190394,19 +190486,30 @@ async function checkoutPrBranch(pr, params) {
190394
190486
  }
190395
190487
  var inFlightCheckouts = /* @__PURE__ */ new Map();
190396
190488
  var CHECKOUT_DEADLINE_MS = 57e4;
190397
- function withCheckoutDeadline(promise2, pullNumber) {
190398
- let timer;
190399
- const deadline = new Promise((_, reject) => {
190400
- timer = setTimeout(
190489
+ var CHECKOUT_ABORT_GRACE_MS = 15e3;
190490
+ function withCheckoutDeadline(run, pullNumber) {
190491
+ const exceeded = `checkout_pr #${pullNumber} exceeded ${CHECKOUT_DEADLINE_MS / 1e3}s`;
190492
+ const retry = "retry the call to start a fresh attempt.";
190493
+ const controller = new AbortController();
190494
+ const abortTimer = setTimeout(
190495
+ () => controller.abort(new Error(`${exceeded} and was cancelled; ${retry}`)),
190496
+ CHECKOUT_DEADLINE_MS
190497
+ );
190498
+ let backstopTimer;
190499
+ const backstop = new Promise((_, reject) => {
190500
+ backstopTimer = setTimeout(
190401
190501
  () => reject(
190402
190502
  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.`
190503
+ `${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
190504
  )
190405
190505
  ),
190406
- CHECKOUT_DEADLINE_MS
190506
+ CHECKOUT_DEADLINE_MS + CHECKOUT_ABORT_GRACE_MS
190407
190507
  );
190408
190508
  });
190409
- return Promise.race([promise2, deadline]).finally(() => clearTimeout(timer));
190509
+ return Promise.race([run(controller.signal), backstop]).finally(() => {
190510
+ clearTimeout(abortTimer);
190511
+ clearTimeout(backstopTimer);
190512
+ });
190410
190513
  }
190411
190514
  function headsEqual(a, b) {
190412
190515
  if (a.kind === "branch" && b.kind === "branch") return a.name === b.name;
@@ -190418,7 +190521,7 @@ function describeHead(h) {
190418
190521
  return `detached HEAD \`${h.sha}\``;
190419
190522
  }
190420
190523
  function CheckoutPrTool(ctx) {
190421
- const runCheckout = async (pull_number) => {
190524
+ const runCheckout = async (pull_number, signal) => {
190422
190525
  const prResponse = await ctx.octokit.rest.pulls.get({
190423
190526
  owner: ctx.repo.owner,
190424
190527
  repo: ctx.repo.name,
@@ -190447,7 +190550,8 @@ function CheckoutPrTool(ctx) {
190447
190550
  toolState: ctx.toolState,
190448
190551
  shell: ctx.payload.shell,
190449
190552
  postCheckoutScript: ctx.postCheckoutScript,
190450
- beforeSha: primary.beforeSha
190553
+ beforeSha: primary.beforeSha,
190554
+ signal
190451
190555
  });
190452
190556
  const tempDir = process.env.PULLFROG_TEMP_DIR;
190453
190557
  if (!tempDir) {
@@ -190469,7 +190573,7 @@ function CheckoutPrTool(ctx) {
190469
190573
  headSha: checkoutSha
190470
190574
  });
190471
190575
  if (incremental.status === "ok") {
190472
- incrementalDiffPath = join15(
190576
+ incrementalDiffPath = join16(
190473
190577
  tempDir,
190474
190578
  `pr-${pull_number}-${beforeShort}-${headShort}-incremental.diff`
190475
190579
  );
@@ -190486,7 +190590,7 @@ function CheckoutPrTool(ctx) {
190486
190590
  const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
190487
190591
  log.debug(`formatted diff preview (first 100 lines):
190488
190592
  ${diffPreview}`);
190489
- const diffPath = join15(tempDir, `pr-${pull_number}-${headShort}.diff`);
190593
+ const diffPath = join16(tempDir, `pr-${pull_number}-${headShort}.diff`);
190490
190594
  writeFileSync10(diffPath, formatResult.content);
190491
190595
  log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
190492
190596
  primary.diffCoverage = createDiffCoverageState({
@@ -190526,7 +190630,8 @@ ${diffPreview}`);
190526
190630
  files: formatResult.files,
190527
190631
  pullNumber: pull_number,
190528
190632
  baseSha: prResponse.data.base.sha,
190529
- headSha: checkoutSha
190633
+ headSha: checkoutSha,
190634
+ signal
190530
190635
  });
190531
190636
  impactPath = impact.path;
190532
190637
  log.info(
@@ -190632,7 +190737,10 @@ ${dirty}`
190632
190737
  );
190633
190738
  }
190634
190739
  }
190635
- const promise2 = withCheckoutDeadline(runCheckout(pull_number), pull_number);
190740
+ const promise2 = withCheckoutDeadline(
190741
+ (signal) => runCheckout(pull_number, signal),
190742
+ pull_number
190743
+ );
190636
190744
  inFlightCheckouts.set(pull_number, promise2);
190637
190745
  try {
190638
190746
  return await promise2;
@@ -190645,7 +190753,7 @@ ${dirty}`
190645
190753
 
190646
190754
  // mcp/checkSuite.ts
190647
190755
  import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync11 } from "node:fs";
190648
- import { join as join16 } from "node:path";
190756
+ import { join as join17 } from "node:path";
190649
190757
  var GetCheckSuiteLogs = type({
190650
190758
  check_suite_id: type.number.describe("the id from check_suite.id")
190651
190759
  });
@@ -190741,7 +190849,7 @@ function GetCheckSuiteLogsTool(ctx) {
190741
190849
  if (!tempDir) {
190742
190850
  throw new Error("PULLFROG_TEMP_DIR not set");
190743
190851
  }
190744
- const logsDir = join16(tempDir, "ci-logs");
190852
+ const logsDir = join17(tempDir, "ci-logs");
190745
190853
  mkdirSync8(logsDir, { recursive: true });
190746
190854
  const jobResults = [];
190747
190855
  for (const run of failedRuns) {
@@ -190768,7 +190876,7 @@ function GetCheckSuiteLogsTool(ctx) {
190768
190876
  );
190769
190877
  }
190770
190878
  const logsText = await logsResult.text();
190771
- const logPath = join16(logsDir, `job-${job.id}.log`);
190879
+ const logPath = join17(logsDir, `job-${job.id}.log`);
190772
190880
  writeFileSync11(logPath, logsText);
190773
190881
  const analysis = analyzeLog(logsText, 80);
190774
190882
  const failedSteps = job.steps?.filter((s) => s.conclusion === "failure").map((s) => `Step ${s.number}: ${s.name}`) ?? [];
@@ -190818,7 +190926,7 @@ function GetCheckSuiteLogsTool(ctx) {
190818
190926
 
190819
190927
  // mcp/commitInfo.ts
190820
190928
  import { writeFileSync as writeFileSync12 } from "node:fs";
190821
- import { join as join17 } from "node:path";
190929
+ import { join as join18 } from "node:path";
190822
190930
  var CommitInfo = type({
190823
190931
  sha: type.string.describe("the commit SHA (full or abbreviated) to fetch")
190824
190932
  });
@@ -190842,7 +190950,7 @@ function CommitInfoTool(ctx) {
190842
190950
  "PULLFROG_TEMP_DIR not set - get_commit_info must run in pullfrog action context"
190843
190951
  );
190844
190952
  }
190845
- const diffFile = join17(tempDir, `commit-${sha.slice(0, 7)}.diff`);
190953
+ const diffFile = join18(tempDir, `commit-${sha.slice(0, 7)}.diff`);
190846
190954
  writeFileSync12(diffFile, formatResult.content);
190847
190955
  log.debug(`wrote commit diff to ${diffFile} (${formatResult.content.length} bytes)`);
190848
190956
  return {
@@ -190979,14 +191087,14 @@ function ReopenCurrentTool(ctx) {
190979
191087
  import { performance as performance8 } from "node:perf_hooks";
190980
191088
 
190981
191089
  // prep/installNodeDependencies.ts
190982
- import { existsSync as existsSync8 } from "node:fs";
190983
- import { join as join19 } from "node:path";
191090
+ import { existsSync as existsSync9 } from "node:fs";
191091
+ import { join as join20 } from "node:path";
190984
191092
 
190985
191093
  // utils/packageManager.ts
190986
191094
  var import_semver2 = __toESM(require_semver2(), 1);
190987
- import { existsSync as existsSync7 } from "node:fs";
191095
+ import { existsSync as existsSync8 } from "node:fs";
190988
191096
  import { mkdir, readFile as readFile2 } from "node:fs/promises";
190989
- import { delimiter, join as join18 } from "node:path";
191097
+ import { delimiter, join as join19 } from "node:path";
190990
191098
  var SUPPORTED_NAMES = ["npm", "pnpm", "yarn", "bun"];
190991
191099
  var COREPACK_MANAGED = ["pnpm", "yarn"];
190992
191100
  function isSupported(name) {
@@ -191024,8 +191132,8 @@ function parseDevEnginesField(field) {
191024
191132
  };
191025
191133
  }
191026
191134
  async function resolvePackageManagerSpec(cwd) {
191027
- const pkgPath = join18(cwd, "package.json");
191028
- if (!existsSync7(pkgPath)) return null;
191135
+ const pkgPath = join19(cwd, "package.json");
191136
+ if (!existsSync8(pkgPath)) return null;
191029
191137
  let pkg;
191030
191138
  try {
191031
191139
  pkg = JSON.parse(await readFile2(pkgPath, "utf-8"));
@@ -191083,7 +191191,7 @@ async function currentVersion(name) {
191083
191191
  return result.stdout.trim();
191084
191192
  }
191085
191193
  function packageManagerBinDir(tmpdir3) {
191086
- return join18(tmpdir3, "pm-bin");
191194
+ return join19(tmpdir3, "pm-bin");
191087
191195
  }
191088
191196
  async function ensurePackageManager(params) {
191089
191197
  const spec = params.spec;
@@ -191146,7 +191254,7 @@ async function installFallback(name, installSpec) {
191146
191254
  });
191147
191255
  if (result.exitCode !== 0) return result.stderr || `failed to install ${name}`;
191148
191256
  if (name === "deno") {
191149
- const denoPath = join18(process.env.HOME || "", ".deno", "bin");
191257
+ const denoPath = join19(process.env.HOME || "", ".deno", "bin");
191150
191258
  process.env.PATH = `${denoPath}:${process.env.PATH}`;
191151
191259
  }
191152
191260
  log.info(`\xBB installed ${name}`);
@@ -191179,8 +191287,8 @@ async function isCommandAvailable2(command) {
191179
191287
  var installNodeDependencies = {
191180
191288
  name: "installNodeDependencies",
191181
191289
  shouldRun: () => {
191182
- const packageJsonPath = join19(process.cwd(), "package.json");
191183
- return existsSync8(packageJsonPath);
191290
+ const packageJsonPath = join20(process.cwd(), "package.json");
191291
+ return existsSync9(packageJsonPath);
191184
191292
  },
191185
191293
  run: async (options) => {
191186
191294
  const declared = await resolvePackageManagerSpec(process.cwd());
@@ -191274,14 +191382,14 @@ ${errorMessage}`]
191274
191382
  };
191275
191383
 
191276
191384
  // prep/installPythonDependencies.ts
191277
- import { existsSync as existsSync9, readFileSync as readFileSync5 } from "node:fs";
191278
- import { join as join20 } from "node:path";
191385
+ import { existsSync as existsSync10, readFileSync as readFileSync5 } from "node:fs";
191386
+ import { join as join21 } from "node:path";
191279
191387
  function declaresBuildSystem(path4) {
191280
191388
  return /^\s*\[\s*build-system\s*\]/m.test(readFileSync5(path4, "utf8"));
191281
191389
  }
191282
191390
  function configApplies(config3, cwd) {
191283
- const path4 = join20(cwd, config3.file);
191284
- return existsSync9(path4) && (config3.applies?.(path4) ?? true);
191391
+ const path4 = join21(cwd, config3.file);
191392
+ return existsSync10(path4) && (config3.applies?.(path4) ?? true);
191285
191393
  }
191286
191394
  var PYTHON_CONFIGS = [
191287
191395
  {
@@ -191603,7 +191711,7 @@ function AwaitDependencyInstallationTool(ctx) {
191603
191711
 
191604
191712
  // mcp/gh.ts
191605
191713
  import { mkdirSync as mkdirSync9 } from "node:fs";
191606
- import { join as join21 } from "node:path";
191714
+ import { join as join22 } from "node:path";
191607
191715
  var GH_TIMEOUT_MS = 12e4;
191608
191716
  var GhParams = type({
191609
191717
  args: type.string.array().describe(
@@ -191625,7 +191733,7 @@ function GhTool(ctx) {
191625
191733
  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
191734
  parameters: GhParams,
191627
191735
  execute: execute(async (params) => {
191628
- const configDir = join21(ctx.tmpdir, "gh-config");
191736
+ const configDir = join22(ctx.tmpdir, "gh-config");
191629
191737
  mkdirSync9(configDir, { recursive: true });
191630
191738
  const command = ["gh", ...params.args.map(shellQuote)].join(" ");
191631
191739
  const result = await runSandboxed({
@@ -192349,7 +192457,7 @@ function PullRequestInfoTool(ctx) {
192349
192457
 
192350
192458
  // mcp/reviewComments.ts
192351
192459
  import { writeFileSync as writeFileSync13 } from "node:fs";
192352
- import { join as join22 } from "node:path";
192460
+ import { join as join23 } from "node:path";
192353
192461
  var COMMENTS_PER_THREAD = 50;
192354
192462
  var REVIEW_THREADS_QUERY = `
192355
192463
  query ($owner: String!, $name: String!, $prNumber: Int!, $cursor: String) {
@@ -192843,7 +192951,7 @@ function GetReviewCommentsTool(ctx) {
192843
192951
  throw new Error("PULLFROG_TEMP_DIR not set");
192844
192952
  }
192845
192953
  const filename = `review-${params.review_id}-threads.md`;
192846
- const commentsPath = join22(tempDir, filename);
192954
+ const commentsPath = join23(tempDir, filename);
192847
192955
  writeFileSync13(commentsPath, formatted.content);
192848
192956
  log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`);
192849
192957
  return {
@@ -193213,7 +193321,7 @@ function UploadFileTool(ctx) {
193213
193321
 
193214
193322
  // mcp/xrepo.ts
193215
193323
  import { mkdirSync as mkdirSync10, rmSync as rmSync3 } from "node:fs";
193216
- import { join as join23 } from "node:path";
193324
+ import { join as join24 } from "node:path";
193217
193325
  function assertValidRepoName(name) {
193218
193326
  if (!/^[A-Za-z0-9._-]+$/.test(name) || name.startsWith("-") || name.includes("..")) {
193219
193327
  throw new Error(
@@ -193293,7 +193401,7 @@ function CheckoutRepoTool(ctx) {
193293
193401
  return { path: existing.dir, access: existing.access, note: "already checked out." };
193294
193402
  }
193295
193403
  const access = accessFor(ctx, repo);
193296
- const dir = join23(ctx.tmpdir, "xrepo", repo);
193404
+ const dir = join24(ctx.tmpdir, "xrepo", repo);
193297
193405
  mkdirSync10(dir, { recursive: true });
193298
193406
  const state = ensureRepoState(ctx.toolState, { owner, name: repo, dir, access });
193299
193407
  const rc = resolveRepoCtx(ctx, repo);
@@ -193659,6 +193767,7 @@ function isByokSetupError(text) {
193659
193767
  var SECRETS_UNAVAILABLE_MARKER = "couldn't load your Pullfrog secrets";
193660
193768
  var ROUTER_UNFUNDED_MARKER = "your Pullfrog Router balance is empty";
193661
193769
  var CREDENTIAL_REJECTED_MARKER = "was rejected by its provider";
193770
+ var CREDENTIAL_POOL_REFUSED_MARKER = "No configured credential can serve this run";
193662
193771
  var NoUsableCredentialError = class extends Error {
193663
193772
  };
193664
193773
  function throwKeyError(params) {
@@ -193925,7 +194034,8 @@ function isOAuthCredentialExpiredError(text) {
193925
194034
  text
193926
194035
  ) || // the provider no longer recognises the token at all (#1086) — same dead
193927
194036
  // 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)
194037
+ /Could not find the appropriate key in your authentication token/i.test(text) || // the reversed word order: `Encountered invalidated oauth token for user` (#1474).
194038
+ /(?:expired|invalidated|revoked) (?:OAuth|authentication) token/i.test(text) || /Token refresh failed/i.test(text)
193929
194039
  );
193930
194040
  }
193931
194041
  function isMalformedKeyError(text) {
@@ -193972,7 +194082,7 @@ function buildRejectedCredentialError(params) {
193972
194082
  ].join("\n");
193973
194083
  }
193974
194084
  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);
194085
+ const keysUrl = providerDashboard(params.credential)?.keys;
193976
194086
  const issue3 = keysUrl ? `[Issue a new key \u2192](${keysUrl}) \xB7 ` : "";
193977
194087
  return [
193978
194088
  `**Your \`${params.credential}\` ${CREDENTIAL_REJECTED_MARKER}**${detail}, so the agent never ran.`,
@@ -193982,12 +194092,43 @@ function buildRejectedCredentialError(params) {
193982
194092
  `${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
194093
  ].join("\n");
193984
194094
  }
193985
- function providerKeysUrl(credential) {
194095
+ function providerDashboard(credential) {
193986
194096
  for (const [id, provider2] of Object.entries(providers)) {
193987
- if (provider2.envVars.includes(credential)) return PROVIDER_DASHBOARDS[id]?.keys;
194097
+ if (provider2.envVars.includes(credential)) return PROVIDER_DASHBOARDS[id];
193988
194098
  }
193989
194099
  return void 0;
193990
194100
  }
194101
+ function buildCredentialPoolRefusedError(params) {
194102
+ const settingsUrl = `${getApiUrl()}/console/${params.owner}/${params.name}`;
194103
+ return [
194104
+ `**${CREDENTIAL_POOL_REFUSED_MARKER}.** Every credential configured for \`${params.model}\` was rejected or is out of quota, so the agent never ran.`,
194105
+ "",
194106
+ ...params.refused.map(
194107
+ (credential) => `- ${describeRefusal({ ...params, credential, settingsUrl })}`
194108
+ ),
194109
+ "",
194110
+ `[Model settings \u2192](${settingsUrl}) \xB7 [Ask in Discord \u2192](https://discord.gg/8y96raFg8e)`
194111
+ ].join("\n");
194112
+ }
194113
+ function describeRefusal(params) {
194114
+ const credential = params.credential;
194115
+ const verdict = credential.verdict;
194116
+ const workflow = credential.source === "workflow";
194117
+ const head = `\`${credential.name}\` from ${workflow ? "the workflow" : `${credential.source} scope`}: \`${verdict.detail}\` \u2014`;
194118
+ const subscription = SUBSCRIPTION_CREDENTIALS[credential.name];
194119
+ const dashboard = providerDashboard(credential.name);
194120
+ if (verdict.status === "exhausted" && subscription) {
194121
+ 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";
194122
+ return `${head} ${wait}, or add an API key for this provider, which Pullfrog falls back to automatically.`;
194123
+ }
194124
+ if (verdict.status === "exhausted")
194125
+ return `${head} add credit or raise its quota with the provider.${dashboard ? ` [Top up \u2192](${dashboard.billing})` : ""}`;
194126
+ if (subscription)
194127
+ 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})`;
194128
+ 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})`;
194129
+ const issue3 = dashboard?.keys ? `[Issue a new key \u2192](${dashboard.keys}) \xB7 ` : "";
194130
+ return `${head} issue a new key and update the copy Pullfrog uses. ${issue3}${where}`;
194131
+ }
193991
194132
  function formatApiKeyErrorSummary(params) {
193992
194133
  if (params.raw.includes(MISSING_KEY_MARKER)) {
193993
194134
  if (params.raw.startsWith(`**${MISSING_KEY_MARKER}**`)) return params.raw;
@@ -194760,11 +194901,16 @@ async function selectConfiguredCredential(input) {
194760
194901
  if (option.candidate) log.info(`\xBB selected ${option.name} from ${from}`);
194761
194902
  return true;
194762
194903
  }
194763
- refused.push(`${option.name} from ${from}: ${verdict.detail}`);
194904
+ refused.push({ name: option.name, source: option.candidate?.source ?? "workflow", verdict });
194764
194905
  log.info(`\xBB ${option.name} from ${from} unavailable; trying the next credential`);
194765
194906
  }
194766
194907
  throw new Error(
194767
- `all configured credentials for ${model} were rejected or exhausted (${refused.join("; ")}); no other model or provider was selected`
194908
+ buildCredentialPoolRefusedError({
194909
+ model,
194910
+ refused,
194911
+ owner: input.ctx.repo.owner,
194912
+ name: input.ctx.repo.name
194913
+ })
194768
194914
  );
194769
194915
  }
194770
194916
  async function probe(input) {
@@ -194805,7 +194951,7 @@ function activate(input) {
194805
194951
  import { randomUUID as randomUUID6 } from "node:crypto";
194806
194952
  import { writeFileSync as writeFileSync14 } from "node:fs";
194807
194953
  import { createServer as createServer3 } from "node:http";
194808
- import { join as join24 } from "node:path";
194954
+ import { join as join25 } from "node:path";
194809
194955
  var REVOKED_TRAP_MS = 6e4;
194810
194956
  function revokeGitHubToken(token) {
194811
194957
  fetch("https://api.github.com/installation/token", {
@@ -194874,7 +195020,7 @@ async function startGitAuthServer(tmpdir3) {
194874
195020
  function writeAskpassScript(code) {
194875
195021
  const scriptId = randomUUID6();
194876
195022
  const scriptName = `askpass-${scriptId}.js`;
194877
- const scriptPath = join24(tmpdir3, scriptName);
195023
+ const scriptPath = join25(tmpdir3, scriptName);
194878
195024
  const content = [
194879
195025
  `#!/usr/bin/env node`,
194880
195026
  `var a=process.argv[2]||"";`,
@@ -195323,7 +195469,7 @@ function resolveInstructions(ctx) {
195323
195469
 
195324
195470
  // utils/learnings.ts
195325
195471
  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";
195472
+ import { dirname as dirname4, join as join26 } from "node:path";
195327
195473
 
195328
195474
  // utils/learningsTruncate.ts
195329
195475
  var MAX_LEARNINGS_LENGTH = 1e5;
@@ -195341,10 +195487,10 @@ function truncateAtLineBoundary(body, cap) {
195341
195487
  var LEARNINGS_FILE_NAME = "pullfrog-learnings.md";
195342
195488
  var XREPO_LEARNINGS_FILE_NAME = "pullfrog-xrepo-learnings.md";
195343
195489
  function learningsFilePath(tmpdir3) {
195344
- return join25(tmpdir3, LEARNINGS_FILE_NAME);
195490
+ return join26(tmpdir3, LEARNINGS_FILE_NAME);
195345
195491
  }
195346
195492
  function xrepoLearningsFilePath(tmpdir3) {
195347
- return join25(tmpdir3, XREPO_LEARNINGS_FILE_NAME);
195493
+ return join26(tmpdir3, XREPO_LEARNINGS_FILE_NAME);
195348
195494
  }
195349
195495
  async function seedLearningsFile(params) {
195350
195496
  const path4 = learningsFilePath(params.tmpdir);
@@ -195895,7 +196041,9 @@ async function resolveTokens(params) {
195895
196041
  // commit-status check-runs for branch protection. the app already grants
195896
196042
  // checks:write; this scopes the MCP token up to use it.
195897
196043
  checks: "write",
195898
- actions: "read"
196044
+ // write (not read) so postReviewCleanup can dispatch the follow-up re-review
196045
+ // for commits pushed while a review was in flight; read 403s it.
196046
+ actions: "write"
195899
196047
  };
195900
196048
  const mcpToken = await acquireNewToken({
195901
196049
  repos: writeRepos,
@@ -196281,7 +196429,7 @@ async function resolveTrialFallback(ctx) {
196281
196429
 
196282
196430
  // utils/prSummary.ts
196283
196431
  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";
196432
+ import { dirname as dirname5, join as join27 } from "node:path";
196285
196433
  var SUMMARY_FILE_NAME = "pullfrog-summary.md";
196286
196434
  var SUMMARY_SCAFFOLD = `# PR summary
196287
196435
 
@@ -196291,7 +196439,7 @@ var SUMMARY_SCAFFOLD = `# PR summary
196291
196439
  var MIN_SNAPSHOT_LENGTH = 60;
196292
196440
  var MAX_SNAPSHOT_LENGTH = 32768;
196293
196441
  function summaryFilePath(tmpdir3) {
196294
- return join26(tmpdir3, SUMMARY_FILE_NAME);
196442
+ return join27(tmpdir3, SUMMARY_FILE_NAME);
196295
196443
  }
196296
196444
  async function seedSummaryFile(params) {
196297
196445
  const path4 = summaryFilePath(params.tmpdir);
@@ -196560,7 +196708,7 @@ function formatBillingExhaustedBody(diagnostic) {
196560
196708
 
196561
196709
  // utils/runErrorRenderer.ts
196562
196710
  function isProviderModelNotFoundError(message) {
196563
- return message.includes("ProviderModelNotFoundError");
196711
+ return /ProviderModelNotFoundError|\bModel not found: /.test(message);
196564
196712
  }
196565
196713
  function isContextOverflowError(message) {
196566
196714
  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 +196805,7 @@ function detectProviderId(message) {
196657
196805
  if (/requires more credits|Key limit exceeded \(total limit\)|openrouter\.ai/i.test(message)) {
196658
196806
  return "openrouter";
196659
196807
  }
196808
+ if (/platform\.openai\.com/i.test(message)) return "openai";
196660
196809
  return null;
196661
196810
  }
196662
196811
  var OPENROUTER_PRIVACY_URL = "https://openrouter.ai/settings/privacy";
@@ -196754,12 +196903,38 @@ function formatByokSetupSummary(input) {
196754
196903
  ].join("\n");
196755
196904
  }
196756
196905
  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.
196906
+ 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
196907
 
196759
196908
  \`\`\`
196760
196909
  ${input.raw}
196761
196910
  \`\`\``;
196762
196911
  }
196912
+ function formatProviderUsageLimit(input) {
196913
+ return [
196914
+ "**The provider's usage limit for this account has been reached.**",
196915
+ "",
196916
+ "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.",
196917
+ "",
196918
+ `[Model settings \u2192](${getApiUrl()}/console/${input.owner}/${input.name}) \xB7 [Ask in Discord \u2192](https://discord.gg/8y96raFg8e)`,
196919
+ "",
196920
+ `\`\`\`
196921
+ ${input.raw}
196922
+ \`\`\``
196923
+ ].join("\n");
196924
+ }
196925
+ function formatProviderErrorBody(input) {
196926
+ return [
196927
+ "**The model provider returned an error, so this run stopped.**",
196928
+ "",
196929
+ "The error is below. If it names your key, plan or balance, fix that with the provider or pick another model before re-triggering.",
196930
+ "",
196931
+ `[Model settings \u2192](${getApiUrl()}/console/${input.owner}/${input.name}) \xB7 [Ask in Discord \u2192](https://discord.gg/8y96raFg8e)`,
196932
+ "",
196933
+ `\`\`\`
196934
+ ${input.raw}
196935
+ \`\`\``
196936
+ ].join("\n");
196937
+ }
196763
196938
  function renderRunError(input) {
196764
196939
  const billingError = input.routerActive && isRouterKeylimitExhaustedError(input.errorMessage) ? new BillingError(input.errorMessage, { code: "router_keylimit_exhausted" }) : null;
196765
196940
  if (billingError) {
@@ -196775,7 +196950,7 @@ function renderRunError(input) {
196775
196950
  if (input.errorMessage.includes(ROUTER_UNFUNDED_MARKER)) {
196776
196951
  return { summary: input.errorMessage, comment: input.errorMessage };
196777
196952
  }
196778
- if (input.errorMessage.includes(CREDENTIAL_REJECTED_MARKER)) {
196953
+ if (input.errorMessage.includes(CREDENTIAL_REJECTED_MARKER) || input.errorMessage.includes(CREDENTIAL_POOL_REFUSED_MARKER)) {
196779
196954
  return { summary: input.errorMessage, comment: input.errorMessage };
196780
196955
  }
196781
196956
  const isHang = input.errorMessage.startsWith("activity timeout") || input.errorMessage.startsWith("agent still pending");
@@ -196798,6 +196973,16 @@ ${body}`, comment: body };
196798
196973
  });
196799
196974
  return { summary: `### \u274C Pullfrog failed
196800
196975
 
196976
+ ${body}`, comment: body };
196977
+ }
196978
+ if (isProviderUsageLimit(input.errorMessage)) {
196979
+ const body = formatProviderUsageLimit({
196980
+ owner: input.repo.owner,
196981
+ name: input.repo.name,
196982
+ raw: input.errorMessage
196983
+ });
196984
+ return { summary: `### \u274C Pullfrog failed
196985
+
196801
196986
  ${body}`, comment: body };
196802
196987
  }
196803
196988
  if (isProviderMissingCredential(input.errorMessage)) {
@@ -196892,6 +197077,16 @@ ${hangBody}`,
196892
197077
  comment: explained ? hangBody : formatMinimalFailureComment(input.repo)
196893
197078
  };
196894
197079
  }
197080
+ if (input.errorMessage.startsWith("provider error: ")) {
197081
+ const body = formatProviderErrorBody({
197082
+ owner: input.repo.owner,
197083
+ name: input.repo.name,
197084
+ raw: input.errorMessage
197085
+ });
197086
+ return { summary: `### \u274C Pullfrog failed
197087
+
197088
+ ${body}`, comment: body };
197089
+ }
196895
197090
  const genericBody = formatGenericFailure(input.errorMessage);
196896
197091
  return {
196897
197092
  summary: `### \u274C Pullfrog failed
@@ -197702,7 +197897,7 @@ async function main() {
197702
197897
  const repoDir = payload.cwd;
197703
197898
  const preIntrospectionDirty = repoDir ? await dirtyTrackedPaths({ cwd: repoDir }) : null;
197704
197899
  const opencodeCliPath = await agents.opencode.install();
197705
- captureBaselineModels(opencodeCliPath);
197900
+ captureBaselineModels({ cliPath: opencodeCliPath, tmpdir: tmpdir3 });
197706
197901
  const injected = [];
197707
197902
  if (runContext.dbSecrets) {
197708
197903
  for (const [key, value2] of Object.entries(runContext.dbSecrets)) {
@@ -197719,7 +197914,7 @@ async function main() {
197719
197914
  await initializeCredentialPool({ ctx: runContext, slug: payload.model, injected });
197720
197915
  installCodexAuth();
197721
197916
  installXaiAuth();
197722
- captureAuthorizedModels(opencodeCliPath);
197917
+ captureAuthorizedModels({ cliPath: opencodeCliPath, tmpdir: tmpdir3 });
197723
197918
  if (preIntrospectionDirty && repoDir) {
197724
197919
  await restoreDirtiedSince({
197725
197920
  before: preIntrospectionDirty,
@@ -198057,8 +198252,8 @@ ${instructions.user}` : null,
198057
198252
  log.info(instructions.full);
198058
198253
  });
198059
198254
  if (agentId === "opencode") {
198060
- const pluginDir = join27(process.cwd(), ".opencode", "plugin");
198061
- const hasPlugins = existsSync10(pluginDir) && readdirSync2(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
198255
+ const pluginDir = join28(process.cwd(), ".opencode", "plugin");
198256
+ const hasPlugins = existsSync11(pluginDir) && readdirSync2(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
198062
198257
  if (hasPlugins && toolState.dependencyInstallation?.promise) {
198063
198258
  log.info(
198064
198259
  "\xBB .opencode/plugin/ detected \u2014 awaiting dependency installation before agent start"