pullfrog 0.1.7 → 0.1.9

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
@@ -19718,10 +19718,10 @@ var require_core = __commonJS({
19718
19718
  (0, command_1.issueCommand)("set-env", { name }, convertedVal);
19719
19719
  }
19720
19720
  exports.exportVariable = exportVariable;
19721
- function setSecret4(secret) {
19721
+ function setSecret5(secret) {
19722
19722
  (0, command_1.issueCommand)("add-mask", {}, secret);
19723
19723
  }
19724
- exports.setSecret = setSecret4;
19724
+ exports.setSecret = setSecret5;
19725
19725
  function addPath(inputPath) {
19726
19726
  const filePath = process.env["GITHUB_PATH"] || "";
19727
19727
  if (filePath) {
@@ -19732,7 +19732,7 @@ var require_core = __commonJS({
19732
19732
  process.env["PATH"] = `${inputPath}${path3.delimiter}${process.env["PATH"]}`;
19733
19733
  }
19734
19734
  exports.addPath = addPath;
19735
- function getInput4(name, options) {
19735
+ function getInput3(name, options) {
19736
19736
  const val = process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`] || "";
19737
19737
  if (options && options.required && !val) {
19738
19738
  throw new Error(`Input required and not supplied: ${name}`);
@@ -19742,9 +19742,9 @@ var require_core = __commonJS({
19742
19742
  }
19743
19743
  return val.trim();
19744
19744
  }
19745
- exports.getInput = getInput4;
19745
+ exports.getInput = getInput3;
19746
19746
  function getMultilineInput(name, options) {
19747
- const inputs = getInput4(name, options).split("\n").filter((x) => x !== "");
19747
+ const inputs = getInput3(name, options).split("\n").filter((x) => x !== "");
19748
19748
  if (options && options.trimWhitespace === false) {
19749
19749
  return inputs;
19750
19750
  }
@@ -19754,7 +19754,7 @@ var require_core = __commonJS({
19754
19754
  function getBooleanInput(name, options) {
19755
19755
  const trueValue = ["true", "True", "TRUE"];
19756
19756
  const falseValue = ["false", "False", "FALSE"];
19757
- const val = getInput4(name, options);
19757
+ const val = getInput3(name, options);
19758
19758
  if (trueValue.includes(val))
19759
19759
  return true;
19760
19760
  if (falseValue.includes(val))
@@ -19826,14 +19826,14 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``);
19826
19826
  });
19827
19827
  }
19828
19828
  exports.group = group2;
19829
- function saveState(name, value2) {
19829
+ function saveState2(name, value2) {
19830
19830
  const filePath = process.env["GITHUB_STATE"] || "";
19831
19831
  if (filePath) {
19832
19832
  return (0, file_command_1.issueFileCommand)("STATE", (0, file_command_1.prepareKeyValueMessage)(name, value2));
19833
19833
  }
19834
19834
  (0, command_1.issueCommand)("save-state", { name }, (0, utils_1.toCommandValue)(value2));
19835
19835
  }
19836
- exports.saveState = saveState;
19836
+ exports.saveState = saveState2;
19837
19837
  function getState(name) {
19838
19838
  return process.env[`STATE_${name}`] || "";
19839
19839
  }
@@ -47737,7 +47737,7 @@ var require_core3 = __commonJS({
47737
47737
  Object.defineProperty(exports, "__esModule", { value: true });
47738
47738
  var id_1 = require_id();
47739
47739
  var ref_1 = require_ref();
47740
- var core8 = [
47740
+ var core11 = [
47741
47741
  "$schema",
47742
47742
  "$id",
47743
47743
  "$defs",
@@ -47747,7 +47747,7 @@ var require_core3 = __commonJS({
47747
47747
  id_1.default,
47748
47748
  ref_1.default
47749
47749
  ];
47750
- exports.default = core8;
47750
+ exports.default = core11;
47751
47751
  }
47752
47752
  });
47753
47753
 
@@ -97475,14 +97475,14 @@ var require_turndown_cjs = __commonJS({
97475
97475
  } else if (node2.nodeType === 1) {
97476
97476
  replacement = replacementForNode.call(self2, node2);
97477
97477
  }
97478
- return join18(output, replacement);
97478
+ return join19(output, replacement);
97479
97479
  }, "");
97480
97480
  }
97481
97481
  function postProcess(output) {
97482
97482
  var self2 = this;
97483
97483
  this.rules.forEach(function(rule) {
97484
97484
  if (typeof rule.append === "function") {
97485
- output = join18(output, rule.append(self2.options));
97485
+ output = join19(output, rule.append(self2.options));
97486
97486
  }
97487
97487
  });
97488
97488
  return output.replace(/^[\t\r\n]+/, "").replace(/[\t\r\n\s]+$/, "");
@@ -97494,7 +97494,7 @@ var require_turndown_cjs = __commonJS({
97494
97494
  if (whitespace.leading || whitespace.trailing) content = content.trim();
97495
97495
  return whitespace.leading + rule.replacement(content, node2, this.options) + whitespace.trailing;
97496
97496
  }
97497
- function join18(output, replacement) {
97497
+ function join19(output, replacement) {
97498
97498
  var s1 = trimTrailingNewlines(output);
97499
97499
  var s2 = trimLeadingNewlines(replacement);
97500
97500
  var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
@@ -98924,10 +98924,9 @@ var require_fast_content_type_parse = __commonJS({
98924
98924
  });
98925
98925
 
98926
98926
  // main.ts
98927
- var core7 = __toESM(require_core(), 1);
98928
98927
  import { existsSync as existsSync7, readdirSync } from "node:fs";
98929
98928
  import { readFile as readFile4 } from "node:fs/promises";
98930
- import { join as join17 } from "node:path";
98929
+ import { join as join18 } from "node:path";
98931
98930
 
98932
98931
  // node_modules/.pnpm/@ark+util@0.56.0/node_modules/@ark/util/out/arrays.js
98933
98932
  var liftArray = (data) => Array.isArray(data) ? data : [data];
@@ -107762,6 +107761,7 @@ var providers = {
107762
107761
  openai: provider({
107763
107762
  displayName: "OpenAI",
107764
107763
  envVars: ["OPENAI_API_KEY"],
107764
+ managedCredentials: ["CODEX_AUTH_JSON"],
107765
107765
  models: {
107766
107766
  gpt: {
107767
107767
  displayName: "GPT",
@@ -107821,12 +107821,16 @@ var providers = {
107821
107821
  displayName: "Gemini Pro",
107822
107822
  resolve: "google/gemini-3.1-pro-preview",
107823
107823
  openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
107824
- preferred: true,
107825
- subagentModel: "gemini-flash"
107824
+ preferred: true
107825
+ // Inherit (subagents stay on Pro). Google has no in-between tier;
107826
+ // dropping to Flash for review work was a meaningful capability cliff
107827
+ // (Flash missed the catastrophic camelCase/snake_case mismatch in
107828
+ // the v4 e2e test). Pro is cost-effective enough to use for both
107829
+ // orchestrator and lenses.
107826
107830
  },
107827
107831
  "gemini-flash": {
107828
107832
  displayName: "Gemini Flash",
107829
- resolve: "google/gemini-3-flash-preview",
107833
+ resolve: "google/gemini-3.5-flash",
107830
107834
  openRouterResolve: "openrouter/google/gemini-3-flash-preview"
107831
107835
  }
107832
107836
  }
@@ -107841,15 +107845,22 @@ var providers = {
107841
107845
  openRouterResolve: "openrouter/x-ai/grok-4.3",
107842
107846
  preferred: true
107843
107847
  },
107848
+ // legacy aliases — xAI retired the entire fast/code-fast line on
107849
+ // 2026-05-15 (https://docs.x.ai/developers/migration/may-15-deprecation)
107850
+ // and now redirects every deprecated text-model slug to grok-4.3 at
107851
+ // standard pricing. fall back to the live `xai/grok` so the alias
107852
+ // chain resolves to grok-4.3 for both direct-key and OpenRouter users.
107844
107853
  "grok-fast": {
107845
107854
  displayName: "Grok Fast",
107846
107855
  resolve: "xai/grok-4-1-fast",
107847
- openRouterResolve: "openrouter/x-ai/grok-4.1-fast"
107856
+ openRouterResolve: "openrouter/x-ai/grok-4.3",
107857
+ fallback: "xai/grok"
107848
107858
  },
107849
107859
  "grok-code-fast": {
107850
107860
  displayName: "Grok Code Fast",
107851
107861
  resolve: "xai/grok-code-fast-1",
107852
- openRouterResolve: "openrouter/x-ai/grok-code-fast-1"
107862
+ openRouterResolve: "openrouter/x-ai/grok-4.3",
107863
+ fallback: "xai/grok"
107853
107864
  }
107854
107865
  }
107855
107866
  }),
@@ -107963,8 +107974,8 @@ var providers = {
107963
107974
  "gemini-pro": {
107964
107975
  displayName: "Gemini Pro",
107965
107976
  resolve: "opencode/gemini-3.1-pro",
107966
- openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
107967
- subagentModel: "gemini-flash"
107977
+ openRouterResolve: "openrouter/google/gemini-3.1-pro-preview"
107978
+ // Inherit — see google/gemini-pro for rationale.
107968
107979
  },
107969
107980
  "gemini-flash": {
107970
107981
  displayName: "Gemini Flash",
@@ -108076,8 +108087,8 @@ var providers = {
108076
108087
  "gemini-pro": {
108077
108088
  displayName: "Gemini Pro",
108078
108089
  resolve: "openrouter/google/gemini-3.1-pro-preview",
108079
- openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
108080
- subagentModel: "gemini-flash"
108090
+ openRouterResolve: "openrouter/google/gemini-3.1-pro-preview"
108091
+ // Inherit — see google/gemini-pro for rationale.
108081
108092
  },
108082
108093
  "gemini-flash": {
108083
108094
  displayName: "Gemini Flash",
@@ -108180,14 +108191,25 @@ function isBedrockAnthropicId(bedrockModelId) {
108180
108191
  // utils/buildPullfrogFooter.ts
108181
108192
  var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
108182
108193
  var FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
108183
- function formatModelLabel(slug2) {
108184
- const alias = resolveDisplayAlias(slug2) ?? // reverse-lookup: when the caller passes an effective model (proxy or
108194
+ function providerDisplayName(slug2) {
108195
+ try {
108196
+ const key = getModelProvider(slug2);
108197
+ const meta3 = providers[key];
108198
+ return meta3?.displayName ?? key;
108199
+ } catch {
108200
+ return slug2;
108201
+ }
108202
+ }
108203
+ function formatModelLabel(params) {
108204
+ const alias = resolveDisplayAlias(params.model) ?? // reverse-lookup: when the caller passes an effective model (proxy or
108185
108205
  // resolved target like "openrouter/anthropic/claude-opus-4.7") instead of
108186
108206
  // a stored alias slug, find the alias whose resolve target matches so we
108187
108207
  // still render a friendly display name.
108188
- modelAliases.find((a) => a.resolve === slug2 || a.openRouterResolve === slug2);
108189
- if (!alias) return `\`${slug2}\``;
108190
- return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
108208
+ modelAliases.find((a) => a.resolve === params.model || a.openRouterResolve === params.model);
108209
+ const displayName = alias?.displayName ?? params.model;
108210
+ const base = alias?.isFree ? `\`${displayName}\` (free)` : `\`${displayName}\``;
108211
+ if (!params.fallbackFrom) return base;
108212
+ return `${base} (credentials for ${providerDisplayName(params.fallbackFrom)} not configured)`;
108191
108213
  }
108192
108214
  function buildPullfrogFooter(params) {
108193
108215
  const parts = [];
@@ -108205,7 +108227,9 @@ function buildPullfrogFooter(params) {
108205
108227
  parts.push("via [Pullfrog](https://pullfrog.com)");
108206
108228
  }
108207
108229
  if (params.model) {
108208
- parts.push(`Using ${formatModelLabel(params.model)}`);
108230
+ parts.push(
108231
+ `Using ${formatModelLabel({ model: params.model, fallbackFrom: params.fallbackFrom })}`
108232
+ );
108209
108233
  }
108210
108234
  const allParts = [...parts, "[\u{1D54F}](https://x.com/pullfrogai)"];
108211
108235
  return `
@@ -108998,7 +109022,8 @@ function buildCommentFooter(ctx, customParts) {
108998
109022
  jobId: ctx.jobId
108999
109023
  } : void 0,
109000
109024
  customParts,
109001
- model: ctx.toolState.model
109025
+ model: ctx.toolState.model,
109026
+ fallbackFrom: ctx.toolState.modelFallback?.from
109002
109027
  });
109003
109028
  }
109004
109029
  function buildImplementPlanLink(ctx, issueNumber, commentId) {
@@ -109023,7 +109048,7 @@ var Comment = type({
109023
109048
  function CreateCommentTool(ctx) {
109024
109049
  return tool({
109025
109050
  name: "create_issue_comment",
109026
- description: "Create a comment on a GitHub issue or PR. Example: `create_issue_comment({ issueNumber: 1234, body: \"Thanks for the report.\" })`. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' for plan comments.",
109051
+ description: 'Create a comment on a GitHub issue or PR. Example: `create_issue_comment({ issueNumber: 1234, body: "Thanks for the report." })`. For progress/plan updates on the current run use report_progress instead \u2014 plan output (initial post AND revisions) is always posted via report_progress, never via this tool.',
109027
109052
  parameters: Comment,
109028
109053
  execute: execute(async ({ issueNumber, body, type: commentType }) => {
109029
109054
  const bodyWithFooter = addFooter(ctx, body);
@@ -109096,7 +109121,7 @@ function EditCommentTool(ctx) {
109096
109121
  var ReportProgress = type({
109097
109122
  body: type.string.describe("the progress update content to share"),
109098
109123
  "target_plan_comment?": type("boolean").describe(
109099
- "when true, update the existing plan comment (from select_mode lookup) instead of the progress comment; use when editing an existing plan"
109124
+ "for revising an existing plan comment ONLY. set to true only when the PlanEdit checklist from select_mode tells you to (i.e. a prior plan comment was found for this issue). NEVER set on the initial plan post \u2014 the initial plan reuses the run's progress comment and is posted by calling report_progress without this flag."
109100
109125
  )
109101
109126
  });
109102
109127
  async function reportProgress(ctx, params) {
@@ -109643,12 +109668,37 @@ function isActivityNoise(chunk) {
109643
109668
  });
109644
109669
  }
109645
109670
  var _lastActivity = performance2.now();
109671
+ var MAX_TOOL_CALL_SUSPENSION_MS = 15 * 60 * 1e3;
109672
+ var _suspendedAt = null;
109673
+ var _suspensionTimer = null;
109646
109674
  function markActivity() {
109647
109675
  _lastActivity = performance2.now();
109648
109676
  }
109649
109677
  function getIdleMs() {
109678
+ if (_suspendedAt !== null) return 0;
109650
109679
  return Math.round(performance2.now() - _lastActivity);
109651
109680
  }
109681
+ function suspendActivity(maxMs = MAX_TOOL_CALL_SUSPENSION_MS) {
109682
+ if (_suspendedAt !== null) return;
109683
+ _suspendedAt = performance2.now();
109684
+ _suspensionTimer = setTimeout(() => {
109685
+ log.warning(`activity watchdog suspended >${Math.round(maxMs / 1e3)}s \u2014 auto-resuming`);
109686
+ resumeActivity();
109687
+ }, maxMs);
109688
+ _suspensionTimer.unref?.();
109689
+ }
109690
+ function resumeActivity() {
109691
+ if (_suspendedAt === null) return;
109692
+ _suspendedAt = null;
109693
+ if (_suspensionTimer) {
109694
+ clearTimeout(_suspensionTimer);
109695
+ _suspensionTimer = null;
109696
+ }
109697
+ _lastActivity = performance2.now();
109698
+ }
109699
+ function isActivitySuspended() {
109700
+ return _suspendedAt !== null;
109701
+ }
109652
109702
  function wrapWrite(original, onActivity) {
109653
109703
  const wrapped = (chunk, encodingOrCb, cb) => {
109654
109704
  if (!isActivityNoise(chunk)) {
@@ -109881,6 +109931,11 @@ async function spawn(options) {
109881
109931
  `spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms`
109882
109932
  );
109883
109933
  activityCheckIntervalId = setInterval(() => {
109934
+ if (options.isPausedExternally?.()) {
109935
+ lastActivityTime = performance3.now();
109936
+ log.debug(`spawn activity check: pid=${child.pid} paused externally`);
109937
+ return;
109938
+ }
109884
109939
  const idleMs = performance3.now() - lastActivityTime;
109885
109940
  log.debug(
109886
109941
  `spawn activity check: pid=${child.pid} idle=${Math.round(idleMs)}ms / ${activityTimeoutMs}ms`
@@ -137894,7 +137949,7 @@ var require_core4 = /* @__PURE__ */ __commonJSMin(((exports) => {
137894
137949
  Object.defineProperty(exports, "__esModule", { value: true });
137895
137950
  const id_1 = require_id2();
137896
137951
  const ref_1 = require_ref2();
137897
- const core8 = [
137952
+ const core11 = [
137898
137953
  "$schema",
137899
137954
  "$id",
137900
137955
  "$defs",
@@ -137904,7 +137959,7 @@ var require_core4 = /* @__PURE__ */ __commonJSMin(((exports) => {
137904
137959
  id_1.default,
137905
137960
  ref_1.default
137906
137961
  ];
137907
- exports.default = core8;
137962
+ exports.default = core11;
137908
137963
  }));
137909
137964
  var require_limitNumber2 = /* @__PURE__ */ __commonJSMin(((exports) => {
137910
137965
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -142414,7 +142469,7 @@ var import_semver = __toESM(require_semver2(), 1);
142414
142469
  // package.json
142415
142470
  var package_default = {
142416
142471
  name: "pullfrog",
142417
- version: "0.1.7",
142472
+ version: "0.1.9",
142418
142473
  type: "module",
142419
142474
  bin: {
142420
142475
  pullfrog: "dist/cli.mjs",
@@ -142430,6 +142485,7 @@ var package_default = {
142430
142485
  typecheck: "tsc --noEmit",
142431
142486
  build: "node esbuild.config.js && tsc -p tsconfig.exports.json",
142432
142487
  "check:entrypoints": "node scripts/check-entrypoint-imports.ts",
142488
+ docker: "node docker.ts",
142433
142489
  play: "node play.ts",
142434
142490
  runtest: "node test/run.ts",
142435
142491
  scratch: "node scratch.ts",
@@ -142463,7 +142519,7 @@ var package_default = {
142463
142519
  fastmcp: "^3.34.0",
142464
142520
  "file-type": "^21.3.0",
142465
142521
  husky: "^9.0.0",
142466
- "opencode-ai": "1.1.56",
142522
+ "opencode-ai": "1.15.1",
142467
142523
  "package-manager-detector": "^1.6.0",
142468
142524
  picocolors: "^1.1.1",
142469
142525
  semver: "^7.7.3",
@@ -142882,6 +142938,52 @@ function readNumber(params) {
142882
142938
  import { execSync } from "node:child_process";
142883
142939
  import { createHash } from "node:crypto";
142884
142940
  import { readFileSync as readFileSync2, realpathSync, unlinkSync } from "node:fs";
142941
+
142942
+ // utils/shell.ts
142943
+ import { spawnSync as spawnSync2 } from "node:child_process";
142944
+ function $(cmd, args2, options) {
142945
+ const encoding = options?.encoding ?? "utf-8";
142946
+ const env2 = resolveEnv(options?.env);
142947
+ const result = spawnSync2(cmd, args2, {
142948
+ stdio: ["ignore", "pipe", "pipe"],
142949
+ encoding,
142950
+ cwd: options?.cwd,
142951
+ env: env2
142952
+ });
142953
+ const stdout = result.stdout ?? "";
142954
+ const stderr = result.stderr ?? "";
142955
+ if (options?.log !== false) {
142956
+ const canWriteToStdout = process.stdout.isTTY === true;
142957
+ if (stdout) {
142958
+ if (canWriteToStdout) {
142959
+ process.stdout.write(stdout);
142960
+ } else {
142961
+ process.stderr.write(stdout);
142962
+ }
142963
+ }
142964
+ if (stderr) {
142965
+ process.stderr.write(stderr);
142966
+ }
142967
+ }
142968
+ if (result.status !== 0) {
142969
+ const errorResult = {
142970
+ status: result.status ?? -1,
142971
+ stdout,
142972
+ stderr
142973
+ };
142974
+ if (options?.onError) {
142975
+ options.onError(errorResult);
142976
+ return stdout.trim();
142977
+ }
142978
+ const detail = [stderr, stdout].map((s) => s.trim()).filter(Boolean).join("\n");
142979
+ throw new Error(
142980
+ `Command failed with exit code ${errorResult.status}: ${detail || "Unknown error"}`
142981
+ );
142982
+ }
142983
+ return stdout.trim();
142984
+ }
142985
+
142986
+ // utils/gitAuth.ts
142885
142987
  var gitBinary;
142886
142988
  function hashFile(path3) {
142887
142989
  return createHash("sha256").update(readFileSync2(path3)).digest("hex");
@@ -142973,6 +143075,27 @@ ${stdout}` : stderr || stdout || "(no output)";
142973
143075
  }
142974
143076
  }
142975
143077
  }
143078
+ var SHALLOW_UNREACHABLE_PATTERNS = [
143079
+ /Could not read [a-f0-9]{40,64}/,
143080
+ /remote did not send all necessary objects/
143081
+ ];
143082
+ var DEEPEN_RETRY_DEPTH = 1e3;
143083
+ async function $gitFetchWithDeepen(args2, options, label) {
143084
+ try {
143085
+ return await $git("fetch", args2, options);
143086
+ } catch (err) {
143087
+ const msg = err instanceof Error ? err.message : String(err);
143088
+ const isShallowUnreachable = SHALLOW_UNREACHABLE_PATTERNS.some((p) => p.test(msg));
143089
+ if (!isShallowUnreachable) throw err;
143090
+ const isShallow = $("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
143091
+ if (!isShallow) throw err;
143092
+ log.info(
143093
+ `\xBB ${label ?? "git fetch"} hit shallow-unreachable error, retrying with --deepen=${DEEPEN_RETRY_DEPTH}`
143094
+ );
143095
+ const retryArgs = args2.filter((a) => !a.startsWith("--depth="));
143096
+ return await $git("fetch", [`--deepen=${DEEPEN_RETRY_DEPTH}`, ...retryArgs], options);
143097
+ }
143098
+ }
142976
143099
 
142977
143100
  // lifecycle.ts
142978
143101
  var LIFECYCLE_HOOK_TIMEOUT_MS = 6e5;
@@ -142994,6 +143117,7 @@ async function executeLifecycleHook(params) {
142994
143117
  if (result.exitCode !== 0) {
142995
143118
  const output = (result.stderr || result.stdout).trim();
142996
143119
  return {
143120
+ failure: { kind: "exit", output, exitCode: result.exitCode },
142997
143121
  warning: `lifecycle hook '${params.event}' failed with exit code ${result.exitCode}. output: ${output || "(empty)"}. retry the operation if the failure looks flaky (network blips, transient rate limits). do NOT retry if the script is broken (missing commands, syntax errors) or the error is persistent.`
142998
143122
  };
142999
143123
  }
@@ -143004,59 +143128,18 @@ async function executeLifecycleHook(params) {
143004
143128
  if (isTimeout) {
143005
143129
  const minutes = Math.round(LIFECYCLE_HOOK_TIMEOUT_MS / 6e4);
143006
143130
  return {
143131
+ failure: { kind: "timeout" },
143007
143132
  warning: `lifecycle hook '${params.event}' timed out after ${minutes}min. do NOT retry \u2014 the script is likely hung or doing too much work. ask the repo owner to simplify the hook (e.g. move long-running work out of the hook, add caching, or split it).`
143008
143133
  };
143009
143134
  }
143010
143135
  const msg = err instanceof Error ? err.message : String(err);
143011
143136
  return {
143137
+ failure: { kind: "spawn", spawnError: msg },
143012
143138
  warning: `lifecycle hook '${params.event}' failed to spawn: ${msg}. this is likely a transient failure \u2014 retry the operation.`
143013
143139
  };
143014
143140
  }
143015
143141
  }
143016
143142
 
143017
- // utils/shell.ts
143018
- import { spawnSync as spawnSync2 } from "node:child_process";
143019
- function $(cmd, args2, options) {
143020
- const encoding = options?.encoding ?? "utf-8";
143021
- const env2 = resolveEnv(options?.env);
143022
- const result = spawnSync2(cmd, args2, {
143023
- stdio: ["ignore", "pipe", "pipe"],
143024
- encoding,
143025
- cwd: options?.cwd,
143026
- env: env2
143027
- });
143028
- const stdout = result.stdout ?? "";
143029
- const stderr = result.stderr ?? "";
143030
- if (options?.log !== false) {
143031
- const canWriteToStdout = process.stdout.isTTY === true;
143032
- if (stdout) {
143033
- if (canWriteToStdout) {
143034
- process.stdout.write(stdout);
143035
- } else {
143036
- process.stderr.write(stdout);
143037
- }
143038
- }
143039
- if (stderr) {
143040
- process.stderr.write(stderr);
143041
- }
143042
- }
143043
- if (result.status !== 0) {
143044
- const errorResult = {
143045
- status: result.status ?? -1,
143046
- stdout,
143047
- stderr
143048
- };
143049
- if (options?.onError) {
143050
- options.onError(errorResult);
143051
- return stdout.trim();
143052
- }
143053
- throw new Error(
143054
- `Command failed with exit code ${errorResult.status}: ${stderr || "Unknown error"}`
143055
- );
143056
- }
143057
- return stdout.trim();
143058
- }
143059
-
143060
143143
  // utils/rangeDiff.ts
143061
143144
  function computeIncrementalDiff(params) {
143062
143145
  try {
@@ -143270,7 +143353,7 @@ function PushBranchTool(ctx) {
143270
143353
  const pushPermission = ctx.payload.push;
143271
143354
  return tool({
143272
143355
  name: "push_branch",
143273
- description: "Push the current branch to the remote repository. Omit branchName to push the current branch (recommended). Example: `push_branch({})` to push the current branch. Example: `push_branch({ branchName: \"pr-1\" })` to push a specific local branch. If specifying branchName, use the LOCAL branch name (e.g., 'pr-1'), not the remote branch name. The correct remote and remote branch are determined automatically from branch config set by checkout_pr. Requires a clean working tree. Runs the repository prepush hook (if configured) before the network push \u2014 hook failure means tests/lint or similar in that script failed, not necessarily a Pullfrog timeout. Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode. If the response reports a timeout, the underlying push may have actually succeeded \u2014 verify with `git log origin/<branch>` (or this tool with command 'log') before retrying, otherwise you'll push a duplicate.",
143356
+ description: "Push the current branch to the remote repository. Omit branchName to push the current branch (recommended). Example: `push_branch({})` to push the current branch. Example: `push_branch({ branchName: \"pr-1\" })` to push a specific local branch. If specifying branchName, use the LOCAL branch name (e.g., 'pr-1'), not the remote branch name. The correct remote and remote branch are determined automatically from branch config set by checkout_pr. Requires a clean working tree. Runs the repository prepush hook (if configured) \u2014 best-effort. If the hook fails, the tool returns the failure output and every subsequent call this run skips the hook. Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode. If the response reports a timeout, the underlying push may have actually succeeded \u2014 verify with `git log origin/<branch>` (or this tool with command 'log') before retrying, otherwise you'll push a duplicate.",
143274
143357
  parameters: PushBranch,
143275
143358
  execute: execute(async ({ branchName, force }) => {
143276
143359
  if (pushPermission === "disabled") {
@@ -143284,10 +143367,21 @@ function PushBranchTool(ctx) {
143284
143367
  `push blocked: working tree is not clean (tracked changes and/or untracked files). commit, discard, or remove stray artifacts before pushing.
143285
143368
 
143286
143369
  git status:
143287
- ${status}`
143370
+ ${status}` + (ctx.toolState.prepushFailureCount > 0 ? "\n\nnote: the prepush hook failed earlier this run \u2014 once the working tree is clean, push_branch will skip the hook." : "")
143288
143371
  );
143289
143372
  }
143290
143373
  const pushDest = validatePushDestination(ctx, branch);
143374
+ const prBranchMatch = branch.match(/^pr-(\d+)$/);
143375
+ if (prBranchMatch && pushDest.remoteBranch !== branch) {
143376
+ const prNumber = Number(prBranchMatch[1]);
143377
+ const event = ctx.payload.event;
143378
+ const runScoped = event.is_pr === true && event.issue_number === prNumber;
143379
+ if (!runScoped) {
143380
+ throw new Error(
143381
+ `push blocked: local branch '${branch}' would push to '${pushDest.remoteName}/${pushDest.remoteBranch}', but this run is not scoped to PR #${prNumber}. the 'pr-${prNumber}' branch was created by a prior checkout_pr call (likely from a subagent \u2014 subagents share the working tree and toolState with the orchestrator). you have probably landed your commit on the wrong branch. switch to your own feature branch first (e.g. 'git checkout <feature-branch>') and then push. if the push to PR #${prNumber} is intentional, this run needs to be triggered against that PR.`
143382
+ );
143383
+ }
143384
+ }
143291
143385
  if (pushPermission === "restricted" && pushDest.remoteBranch === defaultBranch) {
143292
143386
  throw new Error(
143293
143387
  `Push blocked: cannot push directly to default branch '${pushDest.remoteBranch}'. Create a feature branch and open a PR instead.`
@@ -143295,21 +143389,27 @@ ${status}`
143295
143389
  }
143296
143390
  const refspec = branch === pushDest.remoteBranch ? branch : `${branch}:${pushDest.remoteBranch}`;
143297
143391
  const pushArgs = force ? ["--force", "-u", pushDest.remoteName, refspec] : ["-u", pushDest.remoteName, refspec];
143298
- const prepushHook = await executeLifecycleHook({
143299
- event: "prepush",
143300
- script: ctx.prepushScript
143301
- });
143302
- if (prepushHook.warning) {
143303
- throw new Error(prepushHook.warning);
143304
- }
143305
- const postHookStatus = $("git", ["status", "--porcelain"], { log: false });
143306
- if (postHookStatus) {
143307
- throw new Error(
143308
- `push blocked: the prepush hook modified the working tree. those changes are not included in the push. commit or discard them (or change the hook to not mutate tracked files) before retrying.
143392
+ const prepushSkipped = ctx.toolState.prepushFailureCount > 0;
143393
+ if (prepushSkipped) {
143394
+ log.info(`\xBB skipping prepush hook (failed earlier this run)`);
143395
+ } else if (ctx.prepushScript) {
143396
+ const prepushHook = await executeLifecycleHook({
143397
+ event: "prepush",
143398
+ script: ctx.prepushScript
143399
+ });
143400
+ if (prepushHook.failure) {
143401
+ ctx.toolState.prepushFailureCount += 1;
143402
+ throw new Error(buildPrepushFailureMessage(prepushHook.failure, ctx.payload.shell));
143403
+ }
143404
+ const postHookStatus = $("git", ["status", "--porcelain"], { log: false });
143405
+ if (postHookStatus) {
143406
+ throw new Error(
143407
+ `push blocked: the prepush hook modified the working tree. those changes are not included in the push. commit or discard them (or change the hook to not mutate tracked files) before retrying.
143309
143408
 
143310
143409
  git status:
143311
143410
  ${postHookStatus}`
143312
- );
143411
+ );
143412
+ }
143313
143413
  }
143314
143414
  log.debug(`pushing ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`);
143315
143415
  if (force) {
@@ -143362,17 +143462,30 @@ ${integrateStep}
143362
143462
  log.info(
143363
143463
  `\xBB pushed branch ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch} (sha ${pushedSha})`
143364
143464
  );
143465
+ const baseMsg = `successfully pushed ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`;
143466
+ const message = prepushSkipped ? `${baseMsg} (prepush hook skipped \u2014 failed earlier this run).` : baseMsg;
143365
143467
  return {
143366
143468
  success: true,
143367
143469
  branch,
143368
143470
  remoteBranch: pushDest.remoteBranch,
143369
143471
  remote: pushDest.remoteName,
143370
143472
  force,
143371
- message: `successfully pushed ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`
143473
+ prepushSkipped,
143474
+ message
143372
143475
  };
143373
143476
  })
143374
143477
  });
143375
143478
  }
143479
+ function buildPrepushFailureMessage(failure, shell) {
143480
+ const header = failure.kind === "exit" ? `prepush hook failed with exit code ${failure.exitCode}.
143481
+
143482
+ script output:
143483
+ ${failure.output || "(empty)"}` : failure.kind === "timeout" ? `prepush hook timed out \u2014 the script is hung or doing too much work.` : `prepush hook failed to spawn: ${failure.spawnError}.`;
143484
+ const ifRealBug = shell === "disabled" ? `fix it before pushing again \u2014 shell access is disabled in this run, so you can't re-run the hook command yourself.` : `run the hook command yourself via the shell tool to iterate (push_branch will NOT re-run it).`;
143485
+ return `${header}
143486
+
143487
+ this repo's prepush hook is best-effort: the next push_branch call will SKIP the hook and proceed. if the failure is unrelated to your changes (pre-existing breakage, flaky check), just call push_branch again. if it could be a real bug in your code, ${ifRealBug}`;
143488
+ }
143376
143489
  var AUTH_REQUIRED_REDIRECT = {
143377
143490
  push: "use the push_branch tool instead \u2014 it handles authentication and permission checks.",
143378
143491
  fetch: "use the git_fetch tool instead \u2014 it handles authentication.",
@@ -143434,6 +143547,23 @@ function GitTool(ctx) {
143434
143547
  }
143435
143548
  }
143436
143549
  }
143550
+ if (command === "merge-base" && args2.includes("--is-ancestor")) {
143551
+ let isAncestor = true;
143552
+ $("git", [command, ...args2], {
143553
+ log: false,
143554
+ onError: (r) => {
143555
+ if (r.status === 1) {
143556
+ isAncestor = false;
143557
+ return;
143558
+ }
143559
+ const detail = [r.stderr, r.stdout].map((s) => s.trim()).filter(Boolean).join("\n");
143560
+ throw new Error(
143561
+ `git merge-base --is-ancestor failed (exit ${r.status}): ${detail || "Unknown error"}`
143562
+ );
143563
+ }
143564
+ });
143565
+ return { success: true, isAncestor };
143566
+ }
143437
143567
  const output = $("git", [command, ...args2], { log: false });
143438
143568
  const lineCount = output.split("\n").length;
143439
143569
  if (lineCount > COLLAPSE_THRESHOLD) {
@@ -143451,11 +143581,6 @@ var GitFetch = type({
143451
143581
  ref: type.string.describe("Ref to fetch: branch name, tag, or 'pull/N/head' for PRs"),
143452
143582
  depth: type.number.describe("Fetch depth (for shallow clones)").optional()
143453
143583
  });
143454
- var SHALLOW_UNREACHABLE_PATTERNS = [
143455
- /Could not read [a-f0-9]{40,64}/,
143456
- /remote did not send all necessary objects/
143457
- ];
143458
- var DEEPEN_RETRY_DEPTH = 1e3;
143459
143584
  function GitFetchTool(ctx) {
143460
143585
  return tool({
143461
143586
  name: "git_fetch",
@@ -143467,20 +143592,7 @@ function GitFetchTool(ctx) {
143467
143592
  if (params.depth !== void 0) {
143468
143593
  fetchArgs.push(`--depth=${params.depth}`);
143469
143594
  }
143470
- try {
143471
- await $git("fetch", fetchArgs, { token: ctx.gitToken });
143472
- } catch (err) {
143473
- const msg = err instanceof Error ? err.message : String(err);
143474
- const isShallowUnreachable = SHALLOW_UNREACHABLE_PATTERNS.some((p) => p.test(msg));
143475
- const isShallow = isShallowUnreachable && $("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
143476
- if (!isShallow) throw err;
143477
- log.info(
143478
- `\xBB git_fetch hit shallow-unreachable error, retrying with --deepen=${DEEPEN_RETRY_DEPTH}`
143479
- );
143480
- await $git("fetch", [`--deepen=${DEEPEN_RETRY_DEPTH}`, "--no-tags", "origin", params.ref], {
143481
- token: ctx.gitToken
143482
- });
143483
- }
143595
+ await $gitFetchWithDeepen(fetchArgs, { token: ctx.gitToken }, "git_fetch");
143484
143596
  return { success: true, ref: params.ref };
143485
143597
  })
143486
143598
  });
@@ -143691,7 +143803,7 @@ var CreatePullRequestReview = type({
143691
143803
  "1-2 sentence high-level summary with urgency level, critical callouts, and feedback about code outside the diff. Specific feedback on diff lines goes in 'comments' array."
143692
143804
  ).optional(),
143693
143805
  approved: type.boolean.describe(
143694
- "Set to true to submit as an approval. Use for both 'no issues found' and informational `> [!NOTE]` reviews where the PR is mergeable as-is and nothing in the body warrants code changes \u2014 approving also suppresses the Fix-button footer affordance so users don't dispatch a fix run on non-actionable feedback. Reserve approved: false for `> [!IMPORTANT]` (recommended changes) and `> [!CAUTION]` (critical) reviews. Defaults to false (comment-only review). Rejections are not supported."
143806
+ "Set to true to submit as an approval. Use for `> \u2705 No new issues found.` reviews where the PR is mergeable as-is and nothing in the body warrants code changes \u2014 approving also suppresses the Fix-button footer affordance so users don't dispatch a fix run on non-actionable feedback. Reserve approved: false for `> \u2139\uFE0F ...` (minor suggestions inline), `> [!IMPORTANT]` (recommended changes), and `> [!CAUTION]` (critical) reviews. Defaults to false (comment-only review). Rejections are not supported."
143695
143807
  ).optional(),
143696
143808
  commit_id: type.string.describe(
143697
143809
  "Optional SHA of the commit being reviewed. Defaults to latest. Must be the FULL 40-character SHA \u2014 abbreviated SHAs are rejected by GitHub with `422 Unprocessable Entity`. The PR-synchronize event payload's `head_sha` is already full-length."
@@ -144036,7 +144148,8 @@ async function createAndSubmitWithFooter(ctx, params, opts) {
144036
144148
  const footer = buildPullfrogFooter({
144037
144149
  workflowRun: ctx.runId ? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId } : void 0,
144038
144150
  customParts,
144039
- model: ctx.toolState.model
144151
+ model: ctx.toolState.model,
144152
+ fallbackFrom: ctx.toolState.modelFallback?.from
144040
144153
  });
144041
144154
  return await ctx.octokit.rest.pulls.submitReview({
144042
144155
  owner: params.owner,
@@ -144204,10 +144317,10 @@ async function ensureBeforeShaReachable(params) {
144204
144317
  sha: params.sha,
144205
144318
  ref: tempBranch
144206
144319
  }), true);
144207
- await $git(
144208
- "fetch",
144320
+ await $gitFetchWithDeepen(
144209
144321
  ["--no-tags", ...params.isShallow ? ["--depth=1"] : [], "origin", tempBranch],
144210
- { token: params.gitToken }
144322
+ { token: params.gitToken },
144323
+ `before_sha temp branch ${tempBranch}`
144211
144324
  );
144212
144325
  log.debug(`\xBB fetched before_sha via temp branch ${tempBranch}`);
144213
144326
  return true;
@@ -144283,16 +144396,22 @@ async function checkoutPrBranch(pr, params) {
144283
144396
  toolState.checkoutSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
144284
144397
  const alreadyOnBranch = toolState.checkoutSha === pr.headSha;
144285
144398
  log.debug(`\xBB fetching base branch (${pr.baseRef})...`);
144286
- await $git("fetch", ["--no-tags", "origin", pr.baseRef], { token: gitToken });
144399
+ await $gitFetchWithDeepen(
144400
+ ["--no-tags", "origin", pr.baseRef],
144401
+ { token: gitToken },
144402
+ `base branch ${pr.baseRef}`
144403
+ );
144287
144404
  if (!alreadyOnBranch) {
144288
144405
  $("git", ["checkout", "-B", pr.baseRef, `origin/${pr.baseRef}`], { log: false });
144289
144406
  log.debug(`\xBB fetching PR #${pr.number} (${localBranch})...`);
144290
144407
  await retry(
144291
144408
  async () => {
144292
144409
  try {
144293
- await $git("fetch", ["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`], {
144294
- token: gitToken
144295
- });
144410
+ await $gitFetchWithDeepen(
144411
+ ["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`],
144412
+ { token: gitToken },
144413
+ `PR #${pr.number}`
144414
+ );
144296
144415
  } catch (e) {
144297
144416
  const msg = e instanceof Error ? e.message : String(e);
144298
144417
  if (PULL_REF_MISSING_PATTERN.test(msg)) {
@@ -144393,134 +144512,159 @@ async function checkoutPrBranch(pr, params) {
144393
144512
  });
144394
144513
  return { hookWarning: postCheckoutHook.warning };
144395
144514
  }
144515
+ var inFlightCheckouts = /* @__PURE__ */ new Map();
144396
144516
  function CheckoutPrTool(ctx) {
144517
+ const runCheckout = async (pull_number) => {
144518
+ const prResponse = await ctx.octokit.rest.pulls.get({
144519
+ owner: ctx.repo.owner,
144520
+ repo: ctx.repo.name,
144521
+ pull_number
144522
+ });
144523
+ const headRepo = prResponse.data.head.repo;
144524
+ if (!headRepo) {
144525
+ throw new Error(`PR #${pull_number} source repository was deleted`);
144526
+ }
144527
+ const pr = {
144528
+ number: pull_number,
144529
+ headSha: prResponse.data.head.sha,
144530
+ headRef: prResponse.data.head.ref,
144531
+ headRepoFullName: headRepo.full_name,
144532
+ baseRef: prResponse.data.base.ref,
144533
+ baseRepoFullName: prResponse.data.base.repo.full_name,
144534
+ maintainerCanModify: prResponse.data.maintainer_can_modify
144535
+ };
144536
+ const checkoutResult = await checkoutPrBranch(pr, {
144537
+ octokit: ctx.octokit,
144538
+ owner: ctx.repo.owner,
144539
+ name: ctx.repo.name,
144540
+ gitToken: ctx.gitToken,
144541
+ toolState: ctx.toolState,
144542
+ shell: ctx.payload.shell,
144543
+ postCheckoutScript: ctx.postCheckoutScript,
144544
+ beforeSha: ctx.toolState.beforeSha
144545
+ });
144546
+ const tempDir = process.env.PULLFROG_TEMP_DIR;
144547
+ if (!tempDir) {
144548
+ throw new Error(
144549
+ "PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
144550
+ );
144551
+ }
144552
+ const headShort = ctx.toolState.checkoutSha.slice(0, 7);
144553
+ let incrementalDiffPath;
144554
+ if (ctx.toolState.beforeSha && ctx.toolState.checkoutSha) {
144555
+ const beforeShort = ctx.toolState.beforeSha.slice(0, 7);
144556
+ const incremental = computeIncrementalDiff({
144557
+ baseBranch: pr.baseRef,
144558
+ beforeSha: ctx.toolState.beforeSha,
144559
+ headSha: ctx.toolState.checkoutSha
144560
+ });
144561
+ if (incremental) {
144562
+ incrementalDiffPath = join3(
144563
+ tempDir,
144564
+ `pr-${pull_number}-${beforeShort}-${headShort}-incremental.diff`
144565
+ );
144566
+ writeFileSync(incrementalDiffPath, incremental);
144567
+ log.info(
144568
+ `\xBB incremental diff computed (${incremental.length} bytes) \u2192 ${incrementalDiffPath}`
144569
+ );
144570
+ }
144571
+ }
144572
+ const formatResult = await fetchAndFormatPrDiff(ctx, pull_number);
144573
+ const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
144574
+ log.debug(`formatted diff preview (first 100 lines):
144575
+ ${diffPreview}`);
144576
+ const diffPath = join3(tempDir, `pr-${pull_number}-${headShort}.diff`);
144577
+ writeFileSync(diffPath, formatResult.content);
144578
+ log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
144579
+ ctx.toolState.diffCoverage = createDiffCoverageState({
144580
+ diffPath,
144581
+ totalLines: countLines({ content: formatResult.content }),
144582
+ toc: formatResult.toc,
144583
+ previous: ctx.toolState.diffCoverage
144584
+ });
144585
+ log.debug(
144586
+ `\xBB diff coverage initialized: diffPath=${diffPath}, totalLines=${ctx.toolState.diffCoverage.totalLines}, tocEntries=${ctx.toolState.diffCoverage.tocEntries.length}`
144587
+ );
144588
+ const cached4 = /* @__PURE__ */ new Map();
144589
+ for (const file2 of formatResult.files) {
144590
+ cached4.set(file2.filename, commentableLinesForFile(file2.patch));
144591
+ }
144592
+ ctx.toolState.commentableLinesByFile = cached4;
144593
+ ctx.toolState.commentableLinesPullNumber = pull_number;
144594
+ ctx.toolState.commentableLinesCheckoutSha = ctx.toolState.checkoutSha;
144595
+ const incrementalInstructions = incrementalDiffPath ? ` IMPORTANT: incrementalDiffPath contains ONLY the changes since the last reviewed version (computed via range-diff). you MUST read incrementalDiffPath FIRST to understand what changed, then use diffPath for full PR context. do NOT skip the incremental diff.` : "";
144596
+ const COMMIT_LOG_MAX = 200;
144597
+ const baseRange = `origin/${pr.baseRef}..HEAD`;
144598
+ let commitCount = 0;
144599
+ let commitLog = "";
144600
+ let commitLogUnavailable = false;
144601
+ try {
144602
+ commitCount = parseInt(
144603
+ $("git", ["rev-list", "--count", baseRange], { log: false }).trim() || "0",
144604
+ 10
144605
+ );
144606
+ commitLog = $("git", ["log", "--oneline", `--max-count=${COMMIT_LOG_MAX}`, baseRange], {
144607
+ log: false
144608
+ });
144609
+ } catch (err) {
144610
+ commitLogUnavailable = true;
144611
+ log.debug(
144612
+ `\xBB unable to compute commit metadata for ${baseRange}: ${err instanceof Error ? err.message : String(err)}`
144613
+ );
144614
+ }
144615
+ const commitLogTruncated = commitCount > COMMIT_LOG_MAX;
144616
+ const hookWarningInstructions = checkoutResult.hookWarning ? ` HOOK WARNING: the post-checkout lifecycle hook reported a non-fatal failure (see hookWarning). decide whether to retry based on the guidance in that field before proceeding.` : "";
144617
+ const commitLogInstructions = commitLogUnavailable ? ` NOTE: commit metadata is partial (base ref unreachable, likely a shallow fetch). commitCount/commitLog may be 0/empty or incomplete; treat them as "unknown" rather than "no commits", and use \`git log\` directly if you need the full history.` : commitLogTruncated ? ` NOTE: commitLog was capped at ${COMMIT_LOG_MAX} entries out of ${commitCount} commits; use \`git log\` directly if you need the full history.` : "";
144618
+ return {
144619
+ success: true,
144620
+ number: prResponse.data.number,
144621
+ title: prResponse.data.title,
144622
+ body: prResponse.data.body,
144623
+ base: pr.baseRef,
144624
+ localBranch: `pr-${pull_number}`,
144625
+ remoteBranch: `refs/heads/${pr.headRef}`,
144626
+ isFork: pr.headRepoFullName !== pr.baseRepoFullName,
144627
+ maintainerCanModify: pr.maintainerCanModify,
144628
+ url: prResponse.data.html_url,
144629
+ headRepo: pr.headRepoFullName,
144630
+ diffPath,
144631
+ incrementalDiffPath,
144632
+ toc: formatResult.toc,
144633
+ commitCount,
144634
+ commitLog,
144635
+ commitLogTruncated,
144636
+ commitLogUnavailable,
144637
+ hookWarning: checkoutResult.hookWarning,
144638
+ instructions: `the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. use the TOC line ranges as your checklist and read specific files from the diff instead of reading the entire file. for example, if the TOC says "src/foo.ts \u2192 lines 5-42", read lines 5-42 from diffPath to see that file's changes. review files selectively based on relevance rather than reading everything sequentially. to inspect the PR's changed files, use diffPath \u2014 do NOT run \`git diff <base>..<head>\` to re-derive what's already in diffPath. the formatted diff with line numbers is authoritative. \`git log\` and \`git diff --stat\` are fine for commit-range overview, and \`git diff\` / \`git diff --cached\` are fine for inspecting *your own* uncommitted changes \u2014 but PR review content MUST come from diffPath. before your review is submitted, a one-time coverage pre-flight may error listing unread TOC regions. retry the same create_pull_request_review call to proceed \u2014 optionally after reading the listed ranges. the pre-flight will not block again this session. the local branch is 'localBranch' (pr-{number}), not the remote branch name. when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.` + incrementalInstructions + hookWarningInstructions + commitLogInstructions
144639
+ };
144640
+ };
144397
144641
  return tool({
144398
144642
  name: "checkout_pr",
144399
144643
  description: "Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. Returns diffPath pointing to the formatted diff file. Example: `checkout_pr({ pull_number: 1234 })`. Transient fetch timeouts are common \u2014 retry the same call up to a few times before treating the failure as terminal. If the error mentions `.git/shallow.lock: File exists` or `.git/index.lock: File exists`, that's a stale lock from a prior timed-out fetch \u2014 remove it via the shell tool (`rm -f .git/shallow.lock .git/index.lock`) and retry.",
144400
144644
  parameters: CheckoutPr,
144401
144645
  execute: execute(async ({ pull_number }) => {
144402
- const prResponse = await ctx.octokit.rest.pulls.get({
144403
- owner: ctx.repo.owner,
144404
- repo: ctx.repo.name,
144405
- pull_number
144406
- });
144407
- const headRepo = prResponse.data.head.repo;
144408
- if (!headRepo) {
144409
- throw new Error(`PR #${pull_number} source repository was deleted`);
144410
- }
144411
- const pr = {
144412
- number: pull_number,
144413
- headSha: prResponse.data.head.sha,
144414
- headRef: prResponse.data.head.ref,
144415
- headRepoFullName: headRepo.full_name,
144416
- baseRef: prResponse.data.base.ref,
144417
- baseRepoFullName: prResponse.data.base.repo.full_name,
144418
- maintainerCanModify: prResponse.data.maintainer_can_modify
144419
- };
144420
- const checkoutResult = await checkoutPrBranch(pr, {
144421
- octokit: ctx.octokit,
144422
- owner: ctx.repo.owner,
144423
- name: ctx.repo.name,
144424
- gitToken: ctx.gitToken,
144425
- toolState: ctx.toolState,
144426
- shell: ctx.payload.shell,
144427
- postCheckoutScript: ctx.postCheckoutScript,
144428
- beforeSha: ctx.toolState.beforeSha
144429
- });
144430
- const tempDir = process.env.PULLFROG_TEMP_DIR;
144431
- if (!tempDir) {
144432
- throw new Error(
144433
- "PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
144434
- );
144435
- }
144436
- const headShort = ctx.toolState.checkoutSha.slice(0, 7);
144437
- let incrementalDiffPath;
144438
- if (ctx.toolState.beforeSha && ctx.toolState.checkoutSha) {
144439
- const beforeShort = ctx.toolState.beforeSha.slice(0, 7);
144440
- const incremental = computeIncrementalDiff({
144441
- baseBranch: pr.baseRef,
144442
- beforeSha: ctx.toolState.beforeSha,
144443
- headSha: ctx.toolState.checkoutSha
144444
- });
144445
- if (incremental) {
144446
- incrementalDiffPath = join3(
144447
- tempDir,
144448
- `pr-${pull_number}-${beforeShort}-${headShort}-incremental.diff`
144449
- );
144450
- writeFileSync(incrementalDiffPath, incremental);
144451
- log.info(
144452
- `\xBB incremental diff computed (${incremental.length} bytes) \u2192 ${incrementalDiffPath}`
144646
+ const inFlight = inFlightCheckouts.get(pull_number);
144647
+ if (inFlight) {
144648
+ log.info(`\xBB checkout_pr({pull_number:${pull_number}}) already in flight \u2014 sharing result`);
144649
+ return inFlight;
144650
+ }
144651
+ const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }).trim();
144652
+ if (currentBranch !== `pr-${pull_number}`) {
144653
+ const dirty = $("git", ["status", "--porcelain"], { log: false }).trim();
144654
+ if (dirty) {
144655
+ throw new Error(
144656
+ `cannot checkout PR #${pull_number} while the working tree has uncommitted changes. commit, push, or discard them before switching. dirty paths:
144657
+ ${dirty}`
144453
144658
  );
144454
144659
  }
144455
144660
  }
144456
- const formatResult = await fetchAndFormatPrDiff(ctx, pull_number);
144457
- const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
144458
- log.debug(`formatted diff preview (first 100 lines):
144459
- ${diffPreview}`);
144460
- const diffPath = join3(tempDir, `pr-${pull_number}-${headShort}.diff`);
144461
- writeFileSync(diffPath, formatResult.content);
144462
- log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
144463
- ctx.toolState.diffCoverage = createDiffCoverageState({
144464
- diffPath,
144465
- totalLines: countLines({ content: formatResult.content }),
144466
- toc: formatResult.toc,
144467
- previous: ctx.toolState.diffCoverage
144468
- });
144469
- log.debug(
144470
- `\xBB diff coverage initialized: diffPath=${diffPath}, totalLines=${ctx.toolState.diffCoverage.totalLines}, tocEntries=${ctx.toolState.diffCoverage.tocEntries.length}`
144471
- );
144472
- const cached4 = /* @__PURE__ */ new Map();
144473
- for (const file2 of formatResult.files) {
144474
- cached4.set(file2.filename, commentableLinesForFile(file2.patch));
144475
- }
144476
- ctx.toolState.commentableLinesByFile = cached4;
144477
- ctx.toolState.commentableLinesPullNumber = pull_number;
144478
- ctx.toolState.commentableLinesCheckoutSha = ctx.toolState.checkoutSha;
144479
- const incrementalInstructions = incrementalDiffPath ? ` IMPORTANT: incrementalDiffPath contains ONLY the changes since the last reviewed version (computed via range-diff). you MUST read incrementalDiffPath FIRST to understand what changed, then use diffPath for full PR context. do NOT skip the incremental diff.` : "";
144480
- const COMMIT_LOG_MAX = 200;
144481
- const baseRange = `origin/${pr.baseRef}..HEAD`;
144482
- let commitCount = 0;
144483
- let commitLog = "";
144484
- let commitLogUnavailable = false;
144661
+ const promise2 = runCheckout(pull_number);
144662
+ inFlightCheckouts.set(pull_number, promise2);
144485
144663
  try {
144486
- commitCount = parseInt(
144487
- $("git", ["rev-list", "--count", baseRange], { log: false }).trim() || "0",
144488
- 10
144489
- );
144490
- commitLog = $("git", ["log", "--oneline", `--max-count=${COMMIT_LOG_MAX}`, baseRange], {
144491
- log: false
144492
- });
144493
- } catch (err) {
144494
- commitLogUnavailable = true;
144495
- log.debug(
144496
- `\xBB unable to compute commit metadata for ${baseRange}: ${err instanceof Error ? err.message : String(err)}`
144497
- );
144664
+ return await promise2;
144665
+ } finally {
144666
+ inFlightCheckouts.delete(pull_number);
144498
144667
  }
144499
- const commitLogTruncated = commitCount > COMMIT_LOG_MAX;
144500
- const hookWarningInstructions = checkoutResult.hookWarning ? ` HOOK WARNING: the post-checkout lifecycle hook reported a non-fatal failure (see hookWarning). decide whether to retry based on the guidance in that field before proceeding.` : "";
144501
- const commitLogInstructions = commitLogUnavailable ? ` NOTE: commit metadata is partial (base ref unreachable, likely a shallow fetch). commitCount/commitLog may be 0/empty or incomplete; treat them as "unknown" rather than "no commits", and use \`git log\` directly if you need the full history.` : commitLogTruncated ? ` NOTE: commitLog was capped at ${COMMIT_LOG_MAX} entries out of ${commitCount} commits; use \`git log\` directly if you need the full history.` : "";
144502
- return {
144503
- success: true,
144504
- number: prResponse.data.number,
144505
- title: prResponse.data.title,
144506
- body: prResponse.data.body,
144507
- base: pr.baseRef,
144508
- localBranch: `pr-${pull_number}`,
144509
- remoteBranch: `refs/heads/${pr.headRef}`,
144510
- isFork: pr.headRepoFullName !== pr.baseRepoFullName,
144511
- maintainerCanModify: pr.maintainerCanModify,
144512
- url: prResponse.data.html_url,
144513
- headRepo: pr.headRepoFullName,
144514
- diffPath,
144515
- incrementalDiffPath,
144516
- toc: formatResult.toc,
144517
- commitCount,
144518
- commitLog,
144519
- commitLogTruncated,
144520
- commitLogUnavailable,
144521
- hookWarning: checkoutResult.hookWarning,
144522
- instructions: `the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. use the TOC line ranges as your checklist and read specific files from the diff instead of reading the entire file. for example, if the TOC says "src/foo.ts \u2192 lines 5-42", read lines 5-42 from diffPath to see that file's changes. review files selectively based on relevance rather than reading everything sequentially. to inspect the PR's changed files, use diffPath \u2014 do NOT run \`git diff <base>..<head>\` to re-derive what's already in diffPath. the formatted diff with line numbers is authoritative. \`git log\` and \`git diff --stat\` are fine for commit-range overview, and \`git diff\` / \`git diff --cached\` are fine for inspecting *your own* uncommitted changes \u2014 but PR review content MUST come from diffPath. before your review is submitted, a one-time coverage pre-flight may error listing unread TOC regions. retry the same create_pull_request_review call to proceed \u2014 optionally after reading the listed ranges. the pre-flight will not block again this session. the local branch is 'localBranch' (pr-{number}), not the remote branch name. when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.` + incrementalInstructions + hookWarningInstructions + commitLogInstructions
144523
- };
144524
144668
  })
144525
144669
  });
144526
144670
  }
@@ -144838,9 +144982,8 @@ function GetIssueEventsTool(ctx) {
144838
144982
  });
144839
144983
  const relevantEventTypes = /* @__PURE__ */ new Set(["cross_referenced", "referenced"]);
144840
144984
  const parsedEvents = events.flatMap((event) => {
144841
- if (!("event" in event) || !relevantEventTypes.has(event.event)) {
144842
- return [];
144843
- }
144985
+ if (!("event" in event) || typeof event.event !== "string") return [];
144986
+ if (!relevantEventTypes.has(event.event)) return [];
144844
144987
  const baseEvent = {
144845
144988
  event: event.event
144846
144989
  };
@@ -145040,7 +145183,8 @@ function buildPrBodyWithFooter(ctx, body) {
145040
145183
  const footer = buildPullfrogFooter({
145041
145184
  triggeredBy: true,
145042
145185
  workflowRun: ctx.runId ? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId } : void 0,
145043
- model: ctx.toolState.model
145186
+ model: ctx.toolState.model,
145187
+ fallbackFrom: ctx.toolState.modelFallback?.from
145044
145188
  });
145045
145189
  const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
145046
145190
  return `${bodyWithoutFooter}${footer}`;
@@ -145611,7 +145755,9 @@ function ListPullRequestReviewsTool(ctx) {
145611
145755
  body: review.body,
145612
145756
  state: review.state,
145613
145757
  user: review.user?.login,
145614
- submitted_at: review.submitted_at
145758
+ submitted_at: review.submitted_at,
145759
+ commit_id: review.commit_id,
145760
+ html_url: review.html_url
145615
145761
  })),
145616
145762
  count: reviews.length
145617
145763
  };
@@ -145851,6 +145997,14 @@ function detectSandboxMethod() {
145851
145997
  return "none";
145852
145998
  }
145853
145999
  var PROC_CLEANUP = "umount /proc 2>/dev/null; umount /proc 2>/dev/null; mount -t proc proc /proc 2>/dev/null;";
146000
+ var SOCKET_CLEANUP = [
146001
+ "/var/run/docker.sock",
146002
+ "/run/docker.sock",
146003
+ "/var/run/podman/podman.sock",
146004
+ "/run/podman/podman.sock",
146005
+ "/run/containerd/containerd.sock",
146006
+ "/var/run/crio/crio.sock"
146007
+ ].map((path3) => `mount --bind /dev/null ${path3} 2>/dev/null;`).join(" ");
145854
146008
  function spawnShell(params) {
145855
146009
  const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
145856
146010
  const sandboxMethod = detectSandboxMethod();
@@ -145863,7 +146017,14 @@ function spawnShell(params) {
145863
146017
  if (sandboxMethod === "unshare") {
145864
146018
  return spawn2(
145865
146019
  "unshare",
145866
- ["--pid", "--fork", "--mount-proc", "bash", "-c", `${PROC_CLEANUP} ${params.command}`],
146020
+ [
146021
+ "--pid",
146022
+ "--fork",
146023
+ "--mount-proc",
146024
+ "bash",
146025
+ "-c",
146026
+ `${PROC_CLEANUP} ${SOCKET_CLEANUP} ${params.command}`
146027
+ ],
145867
146028
  spawnOpts
145868
146029
  );
145869
146030
  }
@@ -145889,7 +146050,7 @@ function spawnShell(params) {
145889
146050
  "--mount-proc",
145890
146051
  "bash",
145891
146052
  "-c",
145892
- `${PROC_CLEANUP} exec su -p -s /bin/bash ${username} -c '${escaped}'`
146053
+ `${PROC_CLEANUP} ${SOCKET_CLEANUP} exec su -p -s /bin/bash ${username} -c '${escaped}'`
145893
146054
  ],
145894
146055
  { ...spawnOpts, env: {} }
145895
146056
  );
@@ -145916,6 +146077,15 @@ function getTempDir() {
145916
146077
  }
145917
146078
  return tempDir;
145918
146079
  }
146080
+ var MAX_OUTPUT_CHARS = 5e3;
146081
+ function capOutput(output) {
146082
+ if (output.length <= MAX_OUTPUT_CHARS) return output;
146083
+ const fullPath = join7(getTempDir(), `shell-${randomUUID2().slice(0, 8)}.log`);
146084
+ writeFileSync5(fullPath, output);
146085
+ const elided = output.length - MAX_OUTPUT_CHARS;
146086
+ return `... [${elided} chars truncated; full output saved to ${fullPath}] ...
146087
+ ${output.slice(-MAX_OUTPUT_CHARS)}`;
146088
+ }
145919
146089
  function isGitCommand(command) {
145920
146090
  const trimmed = command.trim();
145921
146091
  if (trimmed === "git" || trimmed.startsWith("git ")) return true;
@@ -145934,6 +146104,8 @@ Use this tool to:
145934
146104
  - Execute build tools (npm, pnpm, cargo, make, etc.)
145935
146105
  - Run tests and linters
145936
146106
 
146107
+ Output is capped at ${MAX_OUTPUT_CHARS} chars: if exceeded, only the tail is returned and the full body is saved to a tempfile (path included in the response). Re-read the tempfile with cat/tail/grep when you need more.
146108
+
145937
146109
  Do NOT use this tool for git commands \u2014 use the dedicated git tools instead.`,
145938
146110
  parameters: ShellParams,
145939
146111
  execute: execute(async (params) => {
@@ -146024,12 +146196,13 @@ ${stderr}` : stderr : stdout;
146024
146196
  output = output ? `${output}
146025
146197
  [timed out after ${timeout}ms]` : `[timed out after ${timeout}ms]`;
146026
146198
  const finalExitCode = exitCode ?? (timedOut ? 124 : -1);
146199
+ const trimmed = output.trim();
146027
146200
  if (finalExitCode !== 0) {
146028
146201
  log.info(`shell command failed with exit code ${finalExitCode}: ${params.command}`);
146029
- if (output) log.info(`output: ${output.trim()}`);
146202
+ if (trimmed) log.info(`output: ${trimmed}`);
146030
146203
  }
146031
146204
  return {
146032
- output: output.trim(),
146205
+ output: capOutput(trimmed),
146033
146206
  exit_code: finalExitCode,
146034
146207
  timed_out: timedOut
146035
146208
  };
@@ -146312,52 +146485,145 @@ Report findings clearly with file:line references and quoted evidence where poss
146312
146485
  // modes.ts
146313
146486
  var PR_SUMMARY_FORMAT = `### Default format
146314
146487
 
146315
- Follow this structure exactly:
146488
+ The body has at most three parts in this exact order:
146316
146489
 
146317
- <b>TL;DR</b> \u2014 1-3 sentences on what the PR does and why. Focus on intent, not mechanics.
146318
- NOTE: use HTML bold <b>TL;DR</b>, NOT markdown bold **TL;DR**.
146490
+ 1. **Reviewed changes preamble** \u2014 one bolded inline lead-in describing what was reviewed in this run, a bullet list of the substantive changes, and an HTML comment carrying review metadata for downstream agents.
146491
+ 2. **Cross-cutting issue sections** (zero or more) \u2014 one \`### \` heading per concern, with a human-readable problem write-up and a collapsed \`<details>Technical details</details>\` block underneath.
146492
+ 3. **\`### \u2139\uFE0F Nitpicks\`** at the very bottom (only if there are nits worth surfacing in the body) \u2014 a flat bullet list, no technical-details block.
146319
146493
 
146320
- ### Key changes
146494
+ Inline-vs-body split: concerns that anchor to a specific line go inline (use the \`comments\` parameter). Body \`### \` sections are reserved for concerns that **have no line to anchor to** \u2014 typically because the concern is about *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 implicitly raises but doesn't address*. A concern that anchors to a line but has broad implications still goes inline (use the technical-details block there to capture the implications \u2014 see Inline technical details below). If you found no non-anchorable concerns, the body has zero \`### \` issue sections \u2014 just the preamble + metadata.
146321
146495
 
146322
- - **Short human-readable title** \u2014 1 sentence per change. Write a short prose phrase (title case or sentence case); when you name a file, type, or function, put that name in backticks (e.g. **Add \`TodoTracker\` for live checklists**). A reviewer should understand the full PR from this list alone.
146496
+ ## 1. Reviewed changes preamble
146323
146497
 
146324
- <sub><b>Summary</b> \uFF5C {file_count} files \uFF5C {commit_count} commits \uFF5C base: \`{base}\` \u2190 \`{head}\`</sub>
146325
- NOTE: the metadata line goes AFTER the bullet list, not before it.
146498
+ Open with a single bolded inline lead-in followed immediately by the bullet list (no \`### Key changes\` heading, no \`<b>TL;DR</b>\`):
146326
146499
 
146327
- Then for each key change, a ## section with a short descriptive title that reads like a documentation heading (e.g. ## Live todo checklist tracking).
146500
+ \`\`\`
146501
+ **Reviewed changes** \u2014 one sentence on what was reviewed in this run. For Review (initial), this is what the PR does and why. For IncrementalReview, this is what changed since the prior pullfrog review. Focus on intent, not mechanics.
146502
+
146503
+ - **Short human-readable title** \u2014 1 sentence per substantive change. Write a short prose phrase; when you name a file, type, or function, put that name in backticks (e.g. **Add \\\`TodoTracker\\\` for live checklists**). A reviewer should understand the full reviewed scope from this list alone \u2014 this IS the dispassionate "what was reviewed and what changed" overview, so cover the substantive changes, not just the loudest ones.
146504
+
146505
+ <!--
146506
+ Pullfrog review metadata \u2014 for any agent (or human-with-agent) reading this
146507
+ review. Incorporate the fields below into your understanding of the context
146508
+ this review was made in. The findings below were written against
146509
+ {head_sha_short}; if new commits have landed on {head_ref} since this review
146510
+ was submitted, treat any specific bug, file, or line callout as POTENTIALLY
146511
+ STALE \u2014 re-diff against {head_sha_short} (or trigger a fresh review) and
146512
+ factor commits past {head_sha_short} into your understanding of the current
146513
+ state before acting on findings.
146514
+
146515
+ - Mode: Review (initial) or IncrementalReview (delta against prior pullfrog review)
146516
+ - Files reviewed: {file_count}
146517
+ - Commits reviewed: {commit_count}
146518
+ - Base: {base_ref} ({base_sha_short})
146519
+ - Head: {head_ref} ({head_sha_short})
146520
+ - Reviewed commits:
146521
+ - {sha_short} \u2014 {commit_subject}
146522
+ - ...
146523
+ - Prior pullfrog review: none or {prior_sha_short} ({prior_review_html_url})
146524
+ - Submitted at: {iso_timestamp}
146525
+ -->
146526
+ \`\`\`
146328
146527
 
146329
- <br/>
146528
+ Pull every metadata field from the \`checkout_pr\` tool's response \u2014 file count, commit count, base/head ref + SHA, the commit list. For \`IncrementalReview\` runs, populate \`Prior pullfrog review\` with the prior review's commit_id (short SHA) and \`html_url\` from \`list_pull_request_reviews\`.
146330
146529
 
146331
- ## Example readable section title
146530
+ ## 2. Cross-cutting issue sections (zero or more)
146332
146531
 
146333
- > **Before:** [old behavior/state]<br/>**After:** [new behavior/state]
146334
- IMPORTANT: Before and After MUST be on a SINGLE blockquote line with an inline <br/> between them. Two separate \`>\` lines creates a double line break.
146532
+ For each cross-cutting concern, one \`### \` section. Use this exact shape:
146335
146533
 
146336
- 1-2 sentences of explanation. Break up text with tables, blockquotes, or lists \u2014 NEVER 3+ plain paragraphs in a row.
146534
+ \`\`\`
146535
+ ### {emoji} {short, descriptive title \u2014 what's wrong, not what to do}
146337
146536
 
146338
- If a change warrants deeper explanation, use a blockquoted details/summary framed as a question:
146339
- > <details><summary>How does X work?</summary>
146340
- > Extended explanation here.
146341
- > </details>
146537
+ {Human-readable problem write-up. Describes the PROBLEM only \u2014 what's broken, what the symptom is, what the blast radius is. NO asks, NO suggested fixes, NO "the right thing to do is...". Asks and fixes live in the technical-details block below; the visible part is for the human to *understand* the problem, not to implement it.}
146342
146538
 
146343
- End each section with a file links trail (3-4 key files max):
146344
- [\`file.ts\`](https://github.com/{owner}/{repo}/pull/{number}/files#diff-{sha256hex_of_filepath}) \xB7 ...
146539
+ <details><summary>Technical details</summary>
146345
146540
 
146346
- Single-feature PRs: skip the ## sections. Fold before/after and explanation into the header after key changes.
146541
+ \\\`\\\`\\\`\\\`markdown
146542
+ # {title repeated}
146347
146543
 
146348
- CRITICAL \u2014 GitHub markdown rendering rule:
146349
- GitHub's markdown parser requires a blank line between ALL block-level elements. This includes transitions between: HTML tags (<br/>, <sub>, <details>, <b>, etc.) and markdown syntax (headings, lists, blockquotes, paragraphs). Without a blank line, GitHub treats the following content as a continuation of the HTML block and renders markdown syntax as literal text. ALWAYS separate block-level elements with a blank line.
146544
+ ## Affected sites
146545
+ - {file path:line} \u2014 {what's wrong there}
146546
+ - ...
146350
146547
 
146351
- Rules:
146352
- - \`##\` titles and key-change bullet lead-ins are plain-language summaries; backtick only actual code tokens (files, types, functions) where they appear in the title
146353
- - ALL variable names, identifiers, and file names in body text must be in backticks
146354
- - ALL file references MUST link to the PR Files Changed view. Use the \`diff-<hex>\` anchor precomputed next to each filename in the \`checkout_pr\` TOC \u2014 do NOT run \`sha256sum\` or any other shell command to compute anchors. NEVER fabricate hex strings. If a file is not in the TOC, omit the \`#diff-\` anchor rather than guessing.
146355
- - Add <br/> before each ## heading for visual spacing. Do NOT use horizontal rules (---)
146356
- - Do NOT include raw diff stats like '+123 / -45' or line counts
146357
- - Do NOT include code blocks or repeat diff contents
146358
- - Do NOT include a changelog section \u2014 the key changes list serves this purpose
146359
- - Focus on *intent*, not *what* \u2014 the diff already shows what changed
146360
- - Get the file count and commit count from the checkout_pr metadata, not by counting manually`;
146548
+ ## Required outcome
146549
+ - {what the fix needs to achieve, not how to achieve it}
146550
+ - ...
146551
+
146552
+ ## Suggested approach (optional)
146553
+ {When the fix shape is non-obvious, sketch one or more reasonable directions. Skip when the outcome alone makes the fix obvious.}
146554
+
146555
+ ## Open questions for the human (optional)
146556
+ - {Any decision an implementing agent shouldn't make unilaterally \u2014 pricing thresholds, breaking-change policy, naming, scope of follow-up.}
146557
+ \\\`\\\`\\\`\\\`
146558
+
146559
+ </details>
146560
+ \`\`\`
146561
+
146562
+ Concrete example of the visible part of a non-anchored section (technical-details block unchanged from the template above):
146563
+
146564
+ \`\`\`
146565
+ ### \u2139\uFE0F Legacy \`opencode.ts\` has no documented deletion plan
146566
+
146567
+ The v2 harness lands alongside the v1 file and imports one helper from it. Worth a follow-up issue or a TODO so the next maintainer doesn't have to re-derive the cleanup plan.
146568
+ \`\`\`
146569
+
146570
+ The example's value is its *shape*: a finding about absence (no deletion plan), not a line-anchored bug. Body sections live or die on whether the concern genuinely doesn't fit on a line.
146571
+
146572
+ **Heading severity emoji** \u2014 every \`### \` heading carries one:
146573
+
146574
+ - \u{1F6A8} critical \u2014 blocks merge (data loss, security, broken core flow)
146575
+ - \u26A0\uFE0F important \u2014 must address before merging (regression, missing validation, incorrect behavior)
146576
+ - \u2139\uFE0F informational \u2014 surfaced for awareness; mergeable as-is
146577
+
146578
+ **Visible problem write-up rules:**
146579
+
146580
+ - **No asks, no suggested fixes** in the visible part. The visible portion describes the problem; the technical-details block describes the fix shape and any open questions. The exception: a fix so self-evident that NOT stating it would be weird (e.g. "the typo is missing an 'r'") \u2014 in that case, fold it into the problem statement and skip the suggested-approach block in technical details too.
146581
+ - **Never two successive plain paragraphs.** Every transition between block-level elements must alternate prose with structure: paragraph \u2192 bullet list \u2192 paragraph; paragraph \u2192 code fence \u2192 bullet list; paragraph \u2192 table \u2192 paragraph. Two consecutive paragraphs in a row create a wall of text that's impossible to digest. If you catch yourself writing one, find a way to split it: pull a list out of it, drop a 2-3 line code fence between them, or merge them into a single tighter paragraph.
146582
+ - **Per-paragraph budget:** ~3 sentences max. Past that, you're explaining where you should be structuring.
146583
+ - **Identifier discipline still applies** in the visible part. Lead with behavior in plain English; name an identifier only when it's the subject of the concern or a public surface a reader would recognize. The technical-details block is where dense identifier references belong.
146584
+
146585
+ **Technical-details block rules:**
146586
+
146587
+ - Wrapped in a 4-backtick markdown fence (\`\\\`\\\`\\\`\\\`markdown ... \\\`\\\`\\\`\\\`\`) so it's visually distinct, one-click copyable, and can contain its own 3-backtick code fences without escape gymnastics. The contents are agent-readable \u2014 a fix-agent will pull the body down and use this block as the brief.
146588
+ - File paths and \`file:line\` refs are encouraged (and necessary) \u2014 the next agent uses these to navigate. Identifier density is fine here.
146589
+ - Slightly more verbose than the absolute minimum is OK when it materially helps the next agent: a small code snippet showing the symptom, a short table of mismatched key/column pairs, a one-paragraph "why CI doesn't catch it" note. Skip massive regression-test scaffolding or full route rewrites \u2014 the implementing agent writes those.
146590
+ - Use the four standard sections (\`Affected sites\`, \`Required outcome\`, optional \`Suggested approach\`, optional \`Open questions for the human\`). Skip the optional sections when they wouldn't add anything.
146591
+
146592
+ ## Inline technical details
146593
+
146594
+ Inline comments are short (~2-3 sentences) by default. When an inline finding has broader implications worth recording for a fix-agent \u2014 e.g. a localized bug whose proper fix requires touching several files, or where the right fix depends on a design decision the human needs to make \u2014 append a collapsed \`<details><summary>Technical details</summary>\` block to the inline comment's body. Same shape as the body-section technical-details block (4-backtick fenced markdown, \`## Affected sites\` / \`## Required outcome\` / optional \`## Suggested approach\` / optional \`## Open questions for the human\`).
146595
+
146596
+ GitHub renders the same markdown parser in inline comments as in the review body, so the collapsed-details affordance works the same way. The visible part of the inline comment stays scannable; the depth is one click away for any agent that needs it.
146597
+
146598
+ ## 3. \`### \u2139\uFE0F Nitpicks\` (optional, last section)
146599
+
146600
+ Only when there are nits that for some reason can't be inlined. Filepaths in nit text are fine \u2014 these are simple enough that a human or agent reads once and acts. No technical-details block.
146601
+
146602
+ \`\`\`
146603
+ ### \u2139\uFE0F Nitpicks
146604
+
146605
+ - {nit, with file path inline if useful, \u2264 ~200 chars}
146606
+ - ...
146607
+ \`\`\`
146608
+
146609
+ ## Inline comment shape
146610
+
146611
+ Inline comments use the same severity framing as body \`### \` sections, scaled down for line-anchored use:
146612
+
146613
+ - **Lead with a 1-2 sentence problem statement.** The reader is looking at the line in question, so don't restate what the line says \u2014 describe what's wrong with it. Optionally prefix the visible line with a severity emoji (\u{1F6A8} / \u26A0\uFE0F / \u2139\uFE0F) when severity isn't obvious from context.
146614
+ - **Optional \`<details><summary>Technical details</summary>...</details>\` collapsible** for findings whose technical context (longer file:line references, related-code snippets, suggested approach, regression-risk notes) would overwhelm the human-readable lead-in. Same agent-readable purpose, same 4-backtick fence shape, and same 4-section structure as the body's technical-details block \u2014 see *Inline technical details* above. Encouraged whenever the depth helps a downstream fix-agent; don't force one when the inline lead-in already says everything.
146615
+ - **Visible portion \u2264 2-3 sentences.** If you find yourself writing more, that's the cue to split the depth into the \`Technical details\` collapsible.
146616
+
146617
+ ## Body-wide rules
146618
+
146619
+ - **Inline-vs-body discipline (repeated for emphasis):** anything that anchors to a specific line goes inline (with a \`<details>Technical details</details>\` block when the implications are broad). The body is for non-anchorable concerns only \u2014 absence, sequencing, design decisions, scope questions, architectural risk.
146620
+ - **No \`### Issues found\` heading** above the issue sections \u2014 each \`### \` heading IS the issue.
146621
+ - **Severity emoji on every \`### \` heading** (\u{1F6A8} / \u26A0\uFE0F / \u2139\uFE0F). No emoji on the preamble lead-in or anywhere else.
146622
+ - **GitHub block-level rendering**: GitHub's markdown parser requires a blank line between ALL block-level elements (HTML tags like \`<br/>\`, \`<sub>\`, \`<details>\`, \`<b>\` and markdown syntax like headings, lists, blockquotes, code fences, paragraphs). Without a blank line, GitHub treats following content as a continuation of the HTML block and renders markdown syntax as literal text. ALWAYS separate block-level elements with a blank line.
146623
+ - **Backtick-wrap** every variable, identifier, or file name when you mention one (in either visible or technical-details portions).
146624
+ - **Don't repeat diff content**, don't include raw \`+123 / -45\` stats, don't include a changelog section, don't use horizontal rules (\`---\`).
146625
+ - **Pull file/commit counts from \`checkout_pr\` metadata** \u2014 never count manually.
146626
+ - **Legacy headings REMOVED.** Do not use \`### Key changes\`, \`### Issues found\`, \`<b>TL;DR</b>\`, or \`<sub><b>Summary</b>\`. The new structure subsumes them.`;
146361
146627
  function computeModes(agentId) {
146362
146628
  const t = (toolName) => formatMcpToolRef(agentId, toolName);
146363
146629
  return [
@@ -146399,7 +146665,7 @@ function computeModes(agentId) {
146399
146665
 
146400
146666
  Otherwise delegate the \`${REVIEWER_AGENT_NAME}\` subagent to review your diff with fresh eyes against YOUR TASK. The subagent's baked-in system prompt enforces a non-mutative + non-recursive contract: read-only file/search/web tools and read-only MCP queries only; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch. Enforcement is prose-only \u2014 restate the constraint in your dispatch instructions and do not relax it.
146401
146667
 
146402
- Provide the subagent with YOUR TASK, the output of \`git diff\`, and a tight summary (not raw output) of any lint/typecheck/test failures you fixed during build \u2014 what broke, root cause, the fix \u2014 so it can check that fixes addressed root causes rather than suppressed symptoms; say "no build-phase failures" if the build path was clean. Instruct it to flag bugs, logic errors, missing edge cases, gaps between request and diff, and unintended changes.
146668
+ Provide the subagent with YOUR TASK, the output of \`git diff origin/<base-branch>\` (single-rev form, no \`HEAD\` \u2014 this compares the working tree against the remote base and captures committed + staged + unstaged work; \`main...HEAD\` and \`--cached\` both miss the uncommitted edits Build self-review runs on, since self-review happens BEFORE the commit), and a tight summary (not raw output) of any lint/typecheck/test failures you fixed during build \u2014 what broke, root cause, the fix \u2014 so it can check that fixes addressed root causes rather than suppressed symptoms; say "no build-phase failures" if the build path was clean. Instruct it to flag bugs, logic errors, missing edge cases, gaps between request and diff, and unintended changes.
146403
146669
 
146404
146670
  Delegation + research discipline (distilled from \`/anneal\` canonical \u2014 these are codified learnings from many review rounds, not theoretical best practices):
146405
146671
  - Do NOT summarize what you implemented \u2014 that biases the subagent toward validating the shape of your solution rather than questioning it.
@@ -146408,7 +146674,7 @@ function computeModes(agentId) {
146408
146674
  - Do NOT defect-hunt the diff yourself in parallel with the subagent. Your role is dispatch + evaluation; doing the review yourself reintroduces the implementation bias the subagent is meant to mitigate.
146409
146675
  - For diffs that rely on third-party API contracts, SDK semantics, framework directives, or DB engine specifics, instruct the subagent to verify load-bearing claims via web search and quote source URLs rather than trust training data \u2014 this is the single most common review-quality failure mode.
146410
146676
 
146411
- Review the findings, address valid points, and discard nitpicks or false positives. The reviewer is fallible \u2014 it biases toward *recommending additions* (defensive checks for impossible cases, extra logging, new abstractions used once, comments restating code, tests asserting tautologies, "just-in-case" guards). For each finding, ask: would applying it leave the code more sound, correct, AND elegant? Two-out-of-three is usually a signal to look harder for a fix that gets all three before settling for one that trades elegance for correctness. Reject bloat-shaped findings without applying them, and after applying the rest re-read your diff and be discerning about what *you just changed*: if any fix turned out to be bloat in context, revert it. The goal is code that is sound and correct *while remaining elegant*; the smallest diff that fixes the real defect almost always wins. Then verify only intended changes are present, no debug artifacts or commented-out code remain, no unrelated files were modified. Commit locally via shell (\`git add . && git commit -m "..."\`).
146677
+ Be **discerning** about what comes back. The reviewer is an AI subagent and is fallible \u2014 treat every finding as a hypothesis, not a directive, and **verify each one yourself** against the diff and the code before deciding whether to apply. You are searching for a solution that is **complete, minimal, and elegant** \u2014 you may need to think hard to find it. Do not over-engineer, do not be over-defensive, **do not write AI slop**. Reviewers bias toward *recommending additions*, and that bias has a recognizable slop texture: defensive checks for cases that cannot happen, extra logging, new abstractions used once, comments restating code, tests asserting tautologies, "just-in-case" guards, error handlers for cases the type system already rules out. Reject those. For each surviving finding, ask: would applying it leave the code more sound, correct, AND elegant? Two-out-of-three means look harder for a fix that gets all three before settling. After applying the fixes you accept, re-read your diff and be discerning about what *you just changed*: if any fix turned out to be bloat in context, revert it. Then verify only intended changes are present, no debug artifacts or commented-out code remain, no unrelated files were modified. Commit locally via shell (\`git add . && git commit -m "..."\`).
146412
146678
 
146413
146679
  6. **finalize**:
146414
146680
  - confirm a clean working tree, then push via \`${t("push_branch")}\` (see *SYSTEM* Git rules if this fails \u2014 prepush errors are usually the repo's tests/lint, not infra timeouts)
@@ -146432,7 +146698,8 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
146432
146698
 
146433
146699
  4. For each comment:
146434
146700
  - understand the feedback
146435
- - evaluate whether applying it would leave the code more **sound, correct, AND elegant**. reviewers are fallible and bias toward *recommending additions* (defensive checks for impossible cases, extra abstractions, comments restating obvious code, tests asserting tautologies, "just-in-case" guards). if a request would add bloat \u2014 ceremony without commensurate correctness benefit \u2014 push back in your reply rather than mechanically applying it. two-out-of-three is usually a signal to look harder for a fix that gets all three before settling.
146701
+ - **verify the finding yourself** against the actual code before deciding whether to apply \u2014 every comment (human or agent) is a hypothesis, not a directive. agent reviewers especially are fallible.
146702
+ - you are searching for a solution that is **complete, minimal, and elegant** \u2014 you may need to think hard to find it. do not over-engineer, do not be over-defensive, **do not write AI slop**. reviewers bias toward *recommending additions*, and that bias has a recognizable slop texture: defensive checks for impossible cases, extra abstractions used once, comments restating obvious code, tests asserting tautologies, "just-in-case" guards, error handlers for cases the type system already rules out. reject those. evaluate whether applying the finding would leave the code more **sound, correct, AND elegant**; two-out-of-three is a signal to look harder for a fix that gets all three. if a request would add bloat \u2014 ceremony without commensurate correctness benefit \u2014 push back in your reply rather than mechanically applying it.
146436
146703
  - if the request stands, make the code change using your native tools; otherwise reply explaining why
146437
146704
  - record what was done (or why nothing was done)
146438
146705
 
@@ -146440,11 +146707,13 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
146440
146707
  - test changes, then review the diff before committing \u2014 verify only intended changes are present, no debug artifacts remain, no fix turned out to be bloat in context (revert any that did), and the changes are clean enough that a senior engineer would approve without hesitation
146441
146708
  - commit locally via shell (\`git add . && git commit -m "..."\`)
146442
146709
 
146443
- 6. Finalize:
146710
+ 6. Finalize. Reply + resolve are paired write actions: do BOTH or NEITHER for each thread.
146444
146711
  - confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
146445
- - reply to each comment **exactly once** using \`${t("reply_to_review_comment")}\` \u2014 do not re-emit the same call (the runtime dedupes identical bodies and the second call is wasted)
146446
- - resolve addressed threads via \`${t("resolve_review_thread")}\`
146447
- - call \`${t("report_progress")}\` with a brief summary (or the exact push error if push failed)`
146712
+ - **if push fails**, call \`${t("report_progress")}\` with the exact error and STOP \u2014 do NOT reply or resolve any thread until the fix is live on the remote. Resolving a thread without the fix landing misleads the reviewer.
146713
+ - **on push success**, for each thread you acted on:
146714
+ - reply ONCE via \`${t("reply_to_review_comment")}\`. The \`comment_id\` parameter takes the root comment's numeric \`id=\` (from the first \`comment author=...\` tag in the \`${t("get_review_comments")}\` output) \u2014 NOT the \`thread=\` value; that's a separate GraphQL ID used by resolve. The runtime dedupes identical bodies within a session.
146715
+ - **immediately** call \`${t("resolve_review_thread")}\` with that thread's \`thread=\` value as \`thread_id\`. Resolve every thread where you (a) made the requested code change in full \u2014 partial fixes leave the thread open \u2014 OR (b) replied with a substantive answer the user explicitly asked for. Do NOT resolve threads where you pushed back on the request and the disagreement is unresolved; leave those open for the human to mediate.
146716
+ - call \`${t("report_progress")}\` with a brief summary`
146448
146717
  },
146449
146718
  // Review and IncrementalReview use a 0-or-2+ lens pattern. The default is
146450
146719
  // 0 lenses (orchestrator handles the review solo). Multi-lens (2+
@@ -146461,9 +146730,12 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
146461
146730
  // the Review/IncrementalReview lens fan-out where independence between
146462
146731
  // perspectives is what's being purchased.
146463
146732
  //
146464
- // Deliberate omission vs canonical /anneal: severity categorization in
146465
- // the final message (the review body has its own CAUTION/IMPORTANT
146466
- // framing instead of a severity table).
146733
+ // Severity categorization is split across two surfaces: the opening
146734
+ // callout (CAUTION/IMPORTANT/ℹ️/✅) sets the review's overall tier, and
146735
+ // per-bullet emoji prefixes (🚨/⚠️/ℹ️ in PR_SUMMARY_FORMAT) tag
146736
+ // individual points inside summary sections — scoping severity to the
146737
+ // specific bullet rather than the whole section keeps a section that
146738
+ // mixes a 🚨 and an ℹ️ from being mislabeled by either of them.
146467
146739
  {
146468
146740
  name: "Review",
146469
146741
  description: "Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
@@ -146549,7 +146821,9 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
146549
146821
 
146550
146822
  6. **aggregate & draft**: when the fan-out lands, merge findings; de-dup overlaps (two lenses 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.
146551
146823
 
146552
- for surviving findings, draft inline comments with NEW line numbers from the diff. every comment must be actionable, 2-3 sentences max. 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.
146824
+ **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?); coverage gaps the diff implies but doesn't add; 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. On substantial PRs (migrations, refactors, multi-file rewrites, version bumps that change runtime semantics), at least one such concern almost always exists; if you can't think of any, your bar is probably too high.
146825
+
146826
+ 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. 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.
146553
146827
 
146554
146828
  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.
146555
146829
 
@@ -146557,12 +146831,12 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
146557
146831
 
146558
146832
  The review body is structured as: \`[optional alert blockquote]\` \u2192 \`[PR summary using the default format below]\`. Inline comments are passed via the \`comments\` parameter, not in the body.
146559
146833
 
146560
- GitHub alert blockquotes render at four visual intensities \u2014 the callout is what the author sees first, so pick the one that matches what you want them to do:
146834
+ The opening callout is what the author sees first \u2014 pick the one that matches what you want them to do. Five tiers, from loudest to friendliest:
146561
146835
 
146562
146836
  - \`[!CAUTION]\` \u2014 large red banner. Reads as "this will break something."
146563
146837
  - \`[!IMPORTANT]\` \u2014 large purple banner. Reads as "you need to look at this before merging."
146564
- - \`[!NOTE]\` \u2014 small blue inline callout. Reads as "FYI, here's something worth noting."
146565
- - no callout \u2014 plain text. Reads as routine review output.
146838
+ - \`> \u2139\uFE0F ...\` \u2014 informational blockquote. Reads as "minor suggestions, nothing blocking."
146839
+ - \`> \u2705 ...\` \u2014 green friendly blockquote. Reads as "no concerns, mergeable."
146566
146840
 
146567
146841
  Two reinforcing levers: callout intensity (above) and \`approved\` (which gates the footer Fix-button affordance \u2014 Fix renders on every non-approving review, so \`approved: true\` suppresses it). Wrapping mergeable feedback in \`[!IMPORTANT]\` trains users to click Fix on reviews that don't need fixing. Pick the tier the author's actual next action justifies.
146568
146842
 
@@ -146571,25 +146845,25 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
146571
146845
  - **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):
146572
146846
  \`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\`.
146573
146847
  - **minor suggestions only** (single-line nits, doc/comment polish, defer-able observations, "rough edges"):
146574
- \`approved: false\`. NO alert blockquote. Body opens directly with the PR summary. Include all inline comments via \`comments\`.
146848
+ \`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.
146575
146849
  - **informational observations** (mergeable as-is, nothing actionable \u2014 e.g. prior feedback addressed cleanly, surfacing a minor stale doc reference, calling out something noteworthy without recommending a change):
146576
- \`approved: true\`. Body opens with \`> [!NOTE]\\n> ...\`, followed by the PR summary. Do NOT include inline \`comments\` \u2014 \`[!NOTE]\` 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.
146850
+ \`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.
146577
146851
  - **no actionable issues**:
146578
- \`approved: true\`. Body opens with \`No new issues found.\` followed by the PR summary.
146852
+ \`approved: true\`. Body opens with \`> \u2705 No new issues found.\\n\\n\` followed by the PR summary.
146579
146853
 
146580
146854
  ${PR_SUMMARY_FORMAT}`
146581
146855
  },
146582
- // IncrementalReview shares Review's 0-or-2+ lens pattern but scopes the
146583
- // target to the incremental diff. The "issues must be NEW since the last
146584
- // Pullfrog review" filter lives at aggregation time (step 8), NOT in the
146585
- // subagent prompt pushing the filter into
146586
- // subagents matches the canonical anneal anti-pattern of "list known
146587
- // pre-existing failures — don't flag these" and suppresses signal on
146588
- // regressions the new commits amplified. The review body is just
146589
- // "Reviewed changes" — a separate "Prior review feedback" checklist
146590
- // would duplicate the rolling PR summary snapshot's record of what
146591
- // earlier runs already addressed and add noise to the user-facing
146592
- // body. Same severity-table omission as Review.
146856
+ // IncrementalReview shares Review's 0-or-2+ lens pattern AND its body
146857
+ // format (PR_SUMMARY_FORMAT), scoped to the incremental delta against the
146858
+ // prior pullfrog review. The "issues must be NEW since the last Pullfrog
146859
+ // review" filter lives at aggregation time (step 8), NOT in the subagent
146860
+ // prompt — pushing the filter into subagents matches the canonical anneal
146861
+ // anti-pattern of "list known pre-existing failures — don't flag these"
146862
+ // and suppresses signal on regressions the new commits amplified. A
146863
+ // separate "Prior review feedback" checklist would duplicate the rolling
146864
+ // PR summary snapshot's record of what earlier runs already addressed and
146865
+ // add noise to the user-facing body. Same opening-callout + per-bullet
146866
+ // emoji severity split as Review.
146593
146867
  {
146594
146868
  name: "IncrementalReview",
146595
146869
  description: "Re-review a PR after new commits are pushed; focus on new changes since the last review",
@@ -146601,7 +146875,15 @@ ${PR_SUMMARY_FORMAT}`
146601
146875
 
146602
146876
  3. **incremental scope**: if \`incrementalDiffPath\` is present, read it to see what changed since the last review. this is a range-diff that isolates the net changes, filtering out base branch noise. if not present, fall back to reviewing the full PR diff and determine what changed since Pullfrog's most recent review.
146603
146877
 
146604
- 4. **prior feedback**: fetch previous reviews via \`${t("list_pull_request_reviews")}\`. for the most recent Pullfrog review, call \`${t("get_review_comments")}\` with the review ID to retrieve specific prior line-level feedback. you'll use this to filter your aggregation in step 8 \u2014 anything already flagged in a prior review and not changed by the new commits should not be re-raised. you do NOT need to render this in the review body; the rolling PR summary snapshot is the durable record of what's been addressed.
146878
+ 4. **prior feedback \u2014 read AND retire it**: fetch previous reviews via \`${t("list_pull_request_reviews")}\`, then call \`${t("get_review_comments")}\` on each prior Pullfrog review. Each thread renders as a section whose first line is a fenced tag \`comment author=<login> id=<fullDatabaseId> review=<reviewId> thread=<graphqlId>\`; section headers carry \`[RESOLVED]\` / \`[OUTDATED]\` when relevant. For every **open, Pullfrog-originated** thread, decide and act:
146879
+
146880
+ - **Pullfrog-originated** means the FIRST \`comment author=...\` tag in the section is \`author=pullfrog[bot]\`. The \`*\` marker on individual comments is unrelated \u2014 it flags whether a comment belongs to the queried review, not whether it is the thread root.
146881
+ - **addressed?** read the file at the thread's anchor and judge whether the substantive concern is now resolved by the new commits. Lines being modified isn't enough: reformatting, renaming, or moving the same code elsewhere doesn't address a concern. If the comment raised multiple distinct concerns, ALL must be addressed. The \`[OUTDATED]\` tag means GitHub moved the anchor (line shift, force-push, rename) \u2014 it does NOT mean the concern was addressed; re-read the code at its new location before deciding.
146882
+ - **if addressed**: call \`${t("reply_to_review_comment")}\` with the root tag's numeric \`id=\` as \`comment_id\` (NOT the \`thread=\` value \u2014 that's a separate GraphQL ID used only by resolve) and a one-line body (e.g. \`Addressed in <short-sha>.\`), then call \`${t("resolve_review_thread")}\` with the root tag's \`thread=\` value as \`thread_id\`. Do this BEFORE drafting the new review so the GitHub thread state aligns with the new review by the time it lands.
146883
+ - **if uncertain or partially addressed**: leave open. False-positive resolutions erode trust faster than false negatives.
146884
+ - **scope**: only retire Pullfrog-originated threads. Threads from human reviewers belong to those humans to resolve, even if the commit happened to address them.
146885
+
146886
+ The remaining open threads feed step 8's dedup filter \u2014 anything already flagged and unchanged by the new commits should not be re-raised. The rolling PR summary snapshot is the durable record of retire activity; you don't need to surface it in the review body.
146605
146887
 
146606
146888
  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.**
146607
146889
 
@@ -146647,22 +146929,28 @@ ${PR_SUMMARY_FORMAT}`
146647
146929
  - do NOT pre-shape their output with a finding schema
146648
146930
  - do NOT mention the other lenses (independence is the point)
146649
146931
 
146650
- 8. **aggregate, draft, self-critique**: merge findings (yours + any subagent output if you went multi-lens); 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. draft inline comments with NEW line numbers from the full PR diff \u2014 every comment must be actionable, 2-3 sentences max.
146932
+ 8. **aggregate, draft, self-critique**: merge findings (yours + any subagent output if you went multi-lens); 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.
146933
+
146934
+ **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?); coverage gaps the new commits imply but don't add; 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. On substantial incremental diffs (migrations, refactors, multi-file rewrites, version bumps that change runtime semantics), at least one such concern almost always exists; if you can't think of any, your bar is probably too high.
146651
146935
 
146652
- 9. **build the review body** \u2014 a single "Reviewed changes" section: summarize at the logical-change level, not per-file. each bullet starts with a past-tense verb (e.g. \`- Extracted shared CLI runtime into a single module\`, \`- Renamed package to pullfrog\`). avoid file paths unless they add clarity. if the changes can be described in one sentence, use one sentence \u2014 no bullets needed. do NOT include a separate "Prior review feedback" checklist; 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 pull request instead of an incremental one \u2014 when this happens, you will need to determine what changes have happened since Pullfrog's most recent review.
146936
+ 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.
146937
+
146938
+ 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.
146653
146939
 
146654
146940
  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.
146655
146941
 
146656
- Same callout-intensity ladder as Review mode \u2014 \`[!CAUTION]\` (large red, "will break") \u2192 \`[!IMPORTANT]\` (large purple, "must address before merging") \u2192 \`[!NOTE]\` (small blue, "FYI") \u2192 no callout (plain text). And the same Fix-button lever: the footer renders a Fix button on every non-approving review, so \`approved: true\` suppresses it. Wrapping mergeable feedback in \`[!IMPORTANT]\` trains users to click Fix on reviews that don't need fixing \u2014 pick the tier the author's actual next action justifies.
146942
+ Same callout ladder as Review mode \u2014 \`[!CAUTION]\` (red, "will break") \u2192 \`[!IMPORTANT]\` (purple, "must address before merging") \u2192 \`> \u2139\uFE0F ...\` (informational, "minor suggestions only") \u2192 \`> \u2705 ...\` (green friendly, "no concerns"). Same Fix-button lever: the footer renders a Fix button on every non-approving review, so \`approved: true\` suppresses it. Wrapping mergeable feedback in \`[!IMPORTANT]\` trains users to click Fix on reviews that don't need fixing \u2014 pick the tier the author's actual next action justifies.
146657
146943
 
146658
146944
  Follow these rules:
146659
146945
  - note: the first create_pull_request_review submission may error with a one-time diff-coverage nudge listing unread TOC regions. retry the same call to proceed \u2014 optionally after reading the listed ranges. the pre-flight will not block again this session.
146660
146946
  - 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 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.
146661
- - 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 ...\`, then the Reviewed-changes summary.
146662
- - 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> ...\`, then the Reviewed-changes summary. Do NOT use this tier for nits, style preferences, or "consider also" suggestions.
146663
- - ELSE IF NEW MINOR SUGGESTIONS ONLY (single-line nits, doc/comment polish, defer-able observations, "rough edges"): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens directly with \`Reviewed the following changes:\\n\` (NO alert blockquote), then the Reviewed-changes summary.
146664
- - ELSE IF INFORMATIONAL OBSERVATIONS (mergeable as-is, but worth surfacing \u2014 e.g. prior feedback addressed cleanly with one minor stale doc reference, or a noteworthy positive observation): call \`${t("create_pull_request_review")}\` with \`approved: true\`, NO inline comments, and the review body. body opens with \`> [!NOTE]\\n> ...\` alert, then the Reviewed-changes summary. If a point is concrete enough to anchor to a line, downgrade the whole review to "minor suggestions only" (\`approved: false\`) instead \u2014 \`[!NOTE]\` and inline comments don't mix.
146665
- - 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, you can set \`approved: true\`. body opens with \`No new issues. Reviewed the following changes:\\n\`, then the Reviewed-changes summary.`
146947
+ - 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.
146948
+ - 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.
146949
+ - ELSE IF NEW MINOR SUGGESTIONS ONLY (single-line nits, doc/comment polish, defer-able observations, "rough edges"): 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.
146950
+ - ELSE IF INFORMATIONAL OBSERVATIONS (mergeable as-is, but worth surfacing \u2014 e.g. prior feedback addressed cleanly with one minor stale doc reference, 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.
146951
+ - 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.
146952
+
146953
+ ${PR_SUMMARY_FORMAT}`
146666
146954
  },
146667
146955
  {
146668
146956
  name: "Plan",
@@ -146677,7 +146965,7 @@ ${PR_SUMMARY_FORMAT}`
146677
146965
 
146678
146966
  3. Produce a structured, actionable plan with clear milestones.
146679
146967
 
146680
- 4. Call \`${t("report_progress")}\` with the plan.`
146968
+ 4. Call \`${t("report_progress")}\` with the plan body. Do NOT set \`target_plan_comment\` \u2014 that flag is exclusively for revising an existing plan, and \`${t("select_mode")}\` will route you to a separate PlanEdit checklist when a prior plan comment exists for this issue.`
146681
146969
  },
146682
146970
  {
146683
146971
  name: "Fix",
@@ -146769,6 +147057,7 @@ function initToolState(params) {
146769
147057
  return {
146770
147058
  progressComment: resolved,
146771
147059
  hadProgressComment: !!resolved,
147060
+ prepushFailureCount: 0,
146772
147061
  backgroundProcesses: /* @__PURE__ */ new Map(),
146773
147062
  usageEntries: []
146774
147063
  };
@@ -146868,6 +147157,17 @@ async function installFromNpmTarball(params) {
146868
147157
  // utils/providerErrors.ts
146869
147158
  var statusKey = `\\b(?:status[_ ]?code|http[_ ]?status|status)["']?\\s*[:=]\\s*["']?`;
146870
147159
  var PROVIDER_ERROR_PATTERNS = [
147160
+ // billing-payload patterns come BEFORE bare status-code patterns. providers
147161
+ // commonly return 401 / 429 for billing/quota exhaustion (OpenCode Zen
147162
+ // `CreditsError` / `FreeUsageLimitError`, Gemini `RESOURCE_EXHAUSTED` +
147163
+ // "spending cap", Anthropic "Insufficient balance"). these are non-retryable
147164
+ // and require user-billing action — distinct from a transient auth error or
147165
+ // rate-limit. status-code patterns would otherwise win and surface
147166
+ // "auth error (401)" / "rate limited (429)" with no billing hint. see #778.
147167
+ { regex: /\bCreditsError\b/, label: "provider billing exhausted" },
147168
+ { regex: /\bFreeUsageLimitError\b/, label: "provider billing exhausted" },
147169
+ { regex: /Insufficient balance/i, label: "provider billing exhausted" },
147170
+ { regex: /spending cap/i, label: "provider billing exhausted" },
146871
147171
  // auth patterns must come BEFORE rate-limit patterns. OpenRouter 401 error
146872
147172
  // payloads carry `x-ratelimit-*` response headers in the dump, and the
146873
147173
  // free-form rate-limit regex below would otherwise win on word-boundary
@@ -146902,12 +147202,38 @@ var PROVIDER_ERROR_PATTERNS = [
146902
147202
  // around `limit` rejects keys like `time_limit` or `field_limit`.
146903
147203
  { regex: /["']?\blimit\b["']?\s*:\s*0\b/, label: "zero quota" }
146904
147204
  ];
146905
- function detectProviderError(text) {
147205
+ var EXCERPT_MAX_BYTES = 600;
147206
+ var LINES_BEFORE = 1;
147207
+ var LINES_AFTER = 2;
147208
+ function findProviderErrorMatch(text) {
146906
147209
  for (const entry of PROVIDER_ERROR_PATTERNS) {
146907
- if (entry.regex.test(text)) return entry.label;
147210
+ const m = entry.regex.exec(text);
147211
+ if (!m) continue;
147212
+ return { label: entry.label, excerpt: extractExcerpt(text, m.index) };
146908
147213
  }
146909
147214
  return null;
146910
147215
  }
147216
+ function extractExcerpt(text, matchIndex) {
147217
+ const lineStart = text.lastIndexOf("\n", matchIndex - 1) + 1;
147218
+ const lineEndRaw = text.indexOf("\n", matchIndex);
147219
+ const lineEnd = lineEndRaw === -1 ? text.length : lineEndRaw;
147220
+ let start = lineStart;
147221
+ for (let i = 0; i < LINES_BEFORE && start > 0; i++) {
147222
+ const prev = text.lastIndexOf("\n", start - 2);
147223
+ start = prev < 0 ? 0 : prev + 1;
147224
+ }
147225
+ let end = lineEnd;
147226
+ for (let i = 0; i < LINES_AFTER && end < text.length; i++) {
147227
+ const next2 = text.indexOf("\n", end + 1);
147228
+ end = next2 < 0 ? text.length : next2;
147229
+ }
147230
+ let excerpt = text.slice(start, end);
147231
+ if (excerpt.length > EXCERPT_MAX_BYTES) {
147232
+ excerpt = text.slice(lineStart, lineEnd);
147233
+ if (excerpt.length > EXCERPT_MAX_BYTES) excerpt = excerpt.slice(0, EXCERPT_MAX_BYTES);
147234
+ }
147235
+ return excerpt.trim();
147236
+ }
146911
147237
  var ROUTER_KEYLIMIT_EXHAUSTED_PATTERN = /requires more credits.*?fewer max_tokens|requested up to \d+ tokens.*?can only afford/is;
146912
147238
  function isRouterKeylimitExhaustedError(text) {
146913
147239
  return ROUTER_KEYLIMIT_EXHAUSTED_PATTERN.test(text);
@@ -146968,11 +147294,25 @@ function addSkill(params) {
146968
147294
  );
146969
147295
  if (result.status === 0) {
146970
147296
  log.success(`installed ${params.skill} skill (${params.agent})`);
146971
- } else {
146972
- const stderr = (result.stderr?.toString() || "").trim();
146973
- const errorMsg = result.error ? result.error.message : stderr;
146974
- log.info(`${params.skill} skill install failed: ${errorMsg}`);
147297
+ return;
146975
147298
  }
147299
+ const stdout = (result.stdout?.toString() || "").trim();
147300
+ const stderr = (result.stderr?.toString() || "").trim();
147301
+ const parts = [
147302
+ `exit=${result.status ?? "null"} signal=${result.signal ?? "null"}`,
147303
+ result.error ? `spawn error: ${result.error.message}` : null,
147304
+ stderr ? `stderr:
147305
+ ${tailLines(stderr, 20)}` : null,
147306
+ stdout ? `stdout:
147307
+ ${tailLines(stdout, 20)}` : null
147308
+ ].filter(Boolean);
147309
+ log.warning(`${params.skill} skill install failed \u2014 ${parts.join(" | ")}`);
147310
+ }
147311
+ function tailLines(text, n) {
147312
+ const lines = text.split("\n");
147313
+ if (lines.length <= n) return text;
147314
+ return `...(truncated, last ${n} of ${lines.length} lines)
147315
+ ${lines.slice(-n).join("\n")}`;
146976
147316
  }
146977
147317
 
146978
147318
  // utils/timer.ts
@@ -147069,7 +147409,7 @@ function buildUnsubmittedReviewPrompt(mode) {
147069
147409
  return [
147070
147410
  `MISSING REVIEW OUTPUT \u2014 you selected Review mode but stopped without calling \`create_pull_request_review\`. the user has no visible signal that this run produced anything; the progress comment will be deleted on exit and no review will appear on the PR.`,
147071
147411
  "",
147072
- "call `create_pull_request_review` now with your aggregated review (body + inline comments). pick the tier per the mode prompt \u2014 Review mode has no no-submit exit, so even informational `> [!NOTE]` reviews and `No new issues found.` reviews must be submitted (both use `approved: true`). the first call may error once with a diff-coverage nudge \u2014 retry the same call to proceed.",
147412
+ "call `create_pull_request_review` now with your aggregated review (body + inline comments). pick the tier per the mode prompt \u2014 Review mode has no no-submit exit, so even informational `> \u2705 No new issues found.` reviews must be submitted (with `approved: true`). the first call may error once with a diff-coverage nudge \u2014 retry the same call to proceed.",
147073
147413
  "",
147074
147414
  "do NOT stop again until `create_pull_request_review` has been called successfully."
147075
147415
  ].join("\n");
@@ -147115,6 +147455,11 @@ function buildPostRunPrompt(issues) {
147115
147455
  if (issues.summaryStale) parts.push(buildSummaryStalePrompt(issues.summaryStale.filePath));
147116
147456
  return parts.join("\n\n---\n\n");
147117
147457
  }
147458
+ var REFLECTION_SKIP_MODES = /* @__PURE__ */ new Set(["IncrementalReview"]);
147459
+ function shouldRunReflection(mode) {
147460
+ if (!mode) return true;
147461
+ return !REFLECTION_SKIP_MODES.has(mode);
147462
+ }
147118
147463
  function buildLearningsReflectionPrompt(filePath) {
147119
147464
  return [
147120
147465
  `REFLECTION \u2014 before you finish, think back over this task: did you discover anything about this repo's setup, test commands, conventions, or patterns that is high-confidence and would reliably help future runs?`,
@@ -147126,18 +147471,16 @@ function buildLearningsReflectionPrompt(filePath) {
147126
147471
  `- **no section over ~300 lines.** when a section is approaching that, split it: introduce \`### \` subsections grouping related bullets, or hoist a coherent group into a new top-level \`## \` section. granular sections mean future runs read targeted line ranges instead of slurping the whole file. this is the most important hygiene rule on long-lived repos.`,
147127
147472
  `- if you find a flat unstructured list (legacy content from before this format), restructure it: read it, group related bullets, rewrite the file with \`## \` / \`### \` headings around them. don't preserve bad structure \u2014 fix it.`,
147128
147473
  "",
147474
+ `the only test: would a future run on this repo do its work better because this bullet exists? useful for future runs in this repo \u2014 prevent wasted tool calls, rabbit holes, and mistakes.`,
147475
+ "",
147129
147476
  `bullet hygiene:`,
147130
- `- one fact per line starting with \`- \`. each bullet is ONE specific durable fact, not a paragraph or essay.`,
147131
- `- aim for \u2264 240 chars per bullet. longer bullets are almost always mixing multiple facts that should be split, or burying the durable claim under PR-specific context that should be cut.`,
147132
- `- only add bullets when the finding is high-confidence AND broadly useful AND will still be true in 3+ months. skip speculative, one-off, or "maybe" findings.`,
147133
- `- prune bullets that are clearly wrong, no longer relevant, or low-signal. a focused, accurate file beats a long stale one. compressing two overlapping bullets into one tighter bullet counts as progress.`,
147134
- `- deduplicate against existing entries (in any section) \u2014 if a bullet covers the same fact, update it in place instead of adding a duplicate.`,
147477
+ `- one fact per line starting with \`- \`, \u2264 240 chars.`,
147478
+ `- only add when high-confidence, broadly useful, evergreen.`,
147479
+ `- prune wrong or low-signal bullets; merge overlaps; dedupe across sections.`,
147480
+ "",
147481
+ `don't anchor facts to repo state that will move: PR / review / commit / branch refs, dates, version pins, line numbers. state the rule directly. if it needs the anchor to be load-bearing, it isn't evergreen.`,
147135
147482
  "",
147136
- `do NOT add bullets for:`,
147137
- `- pullfrog tool quirks (e.g. "\`shell\` timeout is in milliseconds", "\`git\` args must be a JSON array", "\`create_pull_request_review\` drops out-of-hunk comments", "\`push_branch\` may report timeout when push succeeded"). these are universal across repos and belong in the tool descriptions \u2014 flag the gap rather than hoarding the workaround per-repo.`,
147138
- `- references to specific PR numbers, review IDs, commit SHAs, branch names, or person handles ("PR #595 introduced X", "flagged in review 12345", "as of commit abc123"). repo state changes; these decay into noise within weeks.`,
147139
- `- dated assertions ("as of May 2026", "currently...", "for now..."). if a fact needs a date to be true, it isn't durable enough to belong here.`,
147140
- `- play-by-play of what THIS run did. learnings are for the NEXT run, not a retrospective.`,
147483
+ `tool-quirk bullets are fine when you burned calls discovering the quirk and a future run would repeat them. write the workaround, not the war story.`,
147141
147484
  "",
147142
147485
  `if you have nothing substantively new to add AND the existing entries still look healthy and well-structured, leave the file alone \u2014 just reply "done" and stop. silence is a valid outcome.`
147143
147486
  ].join("\n");
@@ -147357,7 +147700,7 @@ function stripProviderPrefix(specifier) {
147357
147700
  function resolveEffort(_model) {
147358
147701
  return "high";
147359
147702
  }
147360
- function tailLines(text, maxCodeUnits) {
147703
+ function tailLines2(text, maxCodeUnits) {
147361
147704
  if (text.length <= maxCodeUnits) return text;
147362
147705
  const tail = text.slice(-maxCodeUnits);
147363
147706
  const firstNewline = tail.indexOf("\n");
@@ -147421,6 +147764,7 @@ async function runClaude(params) {
147421
147764
  }
147422
147765
  } else if (block.type === "tool_use") {
147423
147766
  const toolName = block.name || "unknown";
147767
+ suspendActivity();
147424
147768
  if (params.onToolUse) {
147425
147769
  params.onToolUse({
147426
147770
  toolName,
@@ -147465,6 +147809,7 @@ async function runClaude(params) {
147465
147809
  for (const block of content) {
147466
147810
  if (typeof block === "string") continue;
147467
147811
  if (block.type === "tool_result") {
147812
+ resumeActivity();
147468
147813
  timerFor(label).markToolResult();
147469
147814
  const outputContent = typeof block.content === "string" ? block.content : Array.isArray(block.content) ? block.content.map(
147470
147815
  (entry) => typeof entry === "string" ? entry : typeof entry === "object" && entry !== null && "text" in entry ? String(entry.text) : JSON.stringify(entry)
@@ -147553,6 +147898,7 @@ async function runClaude(params) {
147553
147898
  env: params.env,
147554
147899
  activityTimeout: 3e5,
147555
147900
  onActivityTimeout: params.onActivityTimeout,
147901
+ isPausedExternally: isActivitySuspended,
147556
147902
  stdio: ["ignore", "pipe", "pipe"],
147557
147903
  // run claude in its own process group so SIGKILL on activity timeout /
147558
147904
  // outer cancellation reaches any subprocesses it spawns (rg, file
@@ -147612,10 +147958,10 @@ async function runClaude(params) {
147612
147958
  if (!trimmed) return;
147613
147959
  recentStderr.push(trimmed);
147614
147960
  if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
147615
- const providerError = detectProviderError(trimmed);
147616
- if (providerError) {
147617
- lastProviderError = providerError;
147618
- log.info(`\xBB provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
147961
+ const match3 = findProviderErrorMatch(trimmed);
147962
+ if (match3) {
147963
+ lastProviderError = match3.label;
147964
+ log.info(`\xBB provider error detected (${match3.label}): ${match3.excerpt}`);
147619
147965
  } else {
147620
147966
  log.debug(trimmed);
147621
147967
  }
@@ -147646,7 +147992,7 @@ ${stderrContext}`);
147646
147992
  const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
147647
147993
  const stdoutSnapshot = output.toString();
147648
147994
  const stderrSnapshot = recentStderr.join("\n");
147649
- const truncatedStdout = stdoutSnapshot ? tailLines(stdoutSnapshot, 2048) : "";
147995
+ const truncatedStdout = stdoutSnapshot ? tailLines2(stdoutSnapshot, 2048) : "";
147650
147996
  const nonJsonStdoutSnapshot = recentNonJsonStdout.join("\n");
147651
147997
  const errorMessage = lastResultError || stderrSnapshot || nonJsonStdoutSnapshot || truncatedStdout || `unknown error - no output from Claude CLI${errorContext}`;
147652
147998
  log.error(
@@ -147708,6 +148054,7 @@ ${stderrContext}`
147708
148054
  }
147709
148055
  var MANAGED_SETTINGS_DIR = "/etc/claude-code";
147710
148056
  var MANAGED_SETTINGS_PATH = `${MANAGED_SETTINGS_DIR}/managed-settings.json`;
148057
+ var CODEX_AUTH_DENY_PATH = "~/.local/share/opencode/auth.json";
147711
148058
  var managedSettings = {
147712
148059
  allowManagedPermissionRulesOnly: true,
147713
148060
  allowManagedHooksOnly: true,
@@ -147720,12 +148067,16 @@ var managedSettings = {
147720
148067
  "Edit(//proc/**)",
147721
148068
  "Edit(//sys/**)",
147722
148069
  "Glob(//proc/**)",
147723
- "Glob(//sys/**)"
148070
+ "Glob(//sys/**)",
148071
+ `Read(${CODEX_AUTH_DENY_PATH})`,
148072
+ `Grep(${CODEX_AUTH_DENY_PATH})`,
148073
+ `Edit(${CODEX_AUTH_DENY_PATH})`,
148074
+ `Glob(${CODEX_AUTH_DENY_PATH})`
147724
148075
  ]
147725
148076
  },
147726
148077
  sandbox: {
147727
148078
  filesystem: {
147728
- denyRead: ["/proc", "/sys"]
148079
+ denyRead: ["/proc", "/sys", CODEX_AUTH_DENY_PATH]
147729
148080
  }
147730
148081
  }
147731
148082
  };
@@ -147786,14 +148137,21 @@ var claude = agent({
147786
148137
  if (model) {
147787
148138
  baseArgs.push("--model", model);
147788
148139
  }
148140
+ const repoDir = process.cwd();
147789
148141
  const env2 = {
147790
148142
  ...process.env,
147791
- ...homeEnv
148143
+ ...homeEnv,
148144
+ PWD: repoDir
147792
148145
  };
147793
148146
  if (isBedrockRoute) {
147794
148147
  env2.CLAUDE_CODE_USE_BEDROCK = "1";
147795
148148
  }
147796
- const repoDir = process.cwd();
148149
+ if (env2.CLAUDE_CODE_OAUTH_TOKEN && !isBedrockRoute && env2.ANTHROPIC_API_KEY) {
148150
+ log.debug(
148151
+ "\xBB CLAUDE_CODE_OAUTH_TOKEN present \u2014 stripping ANTHROPIC_API_KEY from Claude Code env so the OAuth subscription is used"
148152
+ );
148153
+ delete env2.ANTHROPIC_API_KEY;
148154
+ }
147797
148155
  log.info(`\xBB effort: ${effort}`);
147798
148156
  log.debug(`\xBB starting Pullfrog (Claude Code): node ${baseArgs.join(" ")}`);
147799
148157
  log.debug(`\xBB working directory: ${repoDir}`);
@@ -147813,7 +148171,7 @@ var claude = agent({
147813
148171
  ctx,
147814
148172
  initialResult: result,
147815
148173
  initialUsage: result.usage,
147816
- reflectionPrompt: ctx.toolState.learningsFilePath ? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath) : void 0,
148174
+ reflectionPrompt: ctx.toolState.learningsFilePath && shouldRunReflection(ctx.toolState.selectedMode) ? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath) : void 0,
147817
148175
  canResume: (r) => Boolean(r.sessionId),
147818
148176
  resume: async (c) => {
147819
148177
  const sessionId = c.previousResult.sessionId;
@@ -147827,11 +148185,167 @@ var claude = agent({
147827
148185
  }
147828
148186
  });
147829
148187
 
147830
- // agents/opencode.ts
147831
- import { execFileSync as execFileSync4 } from "node:child_process";
148188
+ // agents/opencode_v2.ts
148189
+ var core2 = __toESM(require_core(), 1);
148190
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync9 } from "node:fs";
148191
+ import { join as join12 } from "node:path";
148192
+ import { performance as performance7 } from "node:perf_hooks";
148193
+
148194
+ // utils/agentHangReport.ts
148195
+ var MAX_STDERR_BYTES = 3e3;
148196
+ function formatAgentHangBody(input) {
148197
+ if (!input.diagnostic) return null;
148198
+ if (input.diagnostic.lastProviderError === "provider billing exhausted") {
148199
+ return formatBillingExhaustedBody(input.diagnostic);
148200
+ }
148201
+ const verb = input.isHang ? "stalled" : "failed";
148202
+ const cause = input.diagnostic.lastProviderError ? ` \u2014 likely cause: \`${input.diagnostic.lastProviderError}\`` : "";
148203
+ const headline = `**${input.diagnostic.label} ${verb}**${cause}`;
148204
+ const explanation = formatExplanation({
148205
+ isHang: input.isHang,
148206
+ errorMessage: input.errorMessage
148207
+ });
148208
+ const parts = [headline, "", `${explanation} ${formatEventsPart(input.diagnostic)}`];
148209
+ const tail = renderStderrTail(input.diagnostic.recentStderr);
148210
+ if (tail) {
148211
+ const fence = pickFence(tail);
148212
+ parts.push(
148213
+ "",
148214
+ "<details><summary>Recent agent stderr</summary>",
148215
+ "",
148216
+ fence,
148217
+ tail,
148218
+ fence,
148219
+ "",
148220
+ "</details>"
148221
+ );
148222
+ }
148223
+ return parts.join("\n");
148224
+ }
148225
+ function formatExplanation(input) {
148226
+ if (!input.isHang) return `The agent exited unexpectedly: ${input.errorMessage}`;
148227
+ const idleSec = parseIdleSec(input.errorMessage);
148228
+ if (idleSec === void 0) {
148229
+ return "The agent stopped emitting events and was killed by the activity-timeout watchdog.";
148230
+ }
148231
+ return `The agent stopped emitting events for ${idleSec}s and was killed by the activity-timeout watchdog.`;
148232
+ }
148233
+ function parseIdleSec(message) {
148234
+ const match3 = /no output for (\d+)s/.exec(message);
148235
+ return match3 ? Number(match3[1]) : void 0;
148236
+ }
148237
+ function formatEventsPart(diagnostic) {
148238
+ if (diagnostic.eventCount > 0) {
148239
+ return `${diagnostic.eventCount} events were processed before the failure.`;
148240
+ }
148241
+ if (diagnostic.lastProviderError) return "No events were emitted before the failure.";
148242
+ return "No events were emitted \u2014 check whether the model provider is reachable.";
148243
+ }
148244
+ function renderStderrTail(lines) {
148245
+ if (lines.length === 0) return "";
148246
+ const joined = lines.join("\n");
148247
+ if (joined.length <= MAX_STDERR_BYTES) return joined;
148248
+ return `... (older lines truncated)
148249
+ ${joined.slice(-MAX_STDERR_BYTES)}`;
148250
+ }
148251
+ function pickFence(content) {
148252
+ let max = 0;
148253
+ for (const match3 of content.matchAll(/`+/g)) {
148254
+ if (match3[0].length > max) max = match3[0].length;
148255
+ }
148256
+ return "`".repeat(Math.max(3, max + 1));
148257
+ }
148258
+ function extractBillingUrl(lines) {
148259
+ const urlPattern = /https:\/\/(?:opencode\.ai\/[^\s"]*billing[^\s"]*|console\.anthropic\.com[^\s"]*|console\.cloud\.google\.com[^\s"]*billing[^\s"]*)/i;
148260
+ for (let i = lines.length - 1; i >= 0; i--) {
148261
+ const m = urlPattern.exec(lines[i] ?? "");
148262
+ if (m) return m[0];
148263
+ }
148264
+ return void 0;
148265
+ }
148266
+ function formatBillingExhaustedBody(diagnostic) {
148267
+ const headline = `**${diagnostic.label} stopped** \u2014 your model provider returned a billing-exhausted response.`;
148268
+ const billingUrl = extractBillingUrl(diagnostic.recentStderr);
148269
+ const cta = billingUrl ? `Top up your provider balance, then re-run: [${billingUrl}](${billingUrl})` : "Top up your model-provider balance (or rotate to a key with remaining credits) and re-run.";
148270
+ const explanation = "The agent kept retrying the request because the provider marked the failure as transient. Pullfrog's activity-timeout watchdog ended the run after no further events were emitted.";
148271
+ const parts = [headline, "", explanation, "", cta];
148272
+ const tail = renderStderrTail(diagnostic.recentStderr);
148273
+ if (tail) {
148274
+ const fence = pickFence(tail);
148275
+ parts.push(
148276
+ "",
148277
+ "<details><summary>Recent agent stderr</summary>",
148278
+ "",
148279
+ fence,
148280
+ tail,
148281
+ fence,
148282
+ "",
148283
+ "</details>"
148284
+ );
148285
+ }
148286
+ return parts.join("\n");
148287
+ }
148288
+
148289
+ // utils/codexHome.ts
147832
148290
  import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync8 } from "node:fs";
148291
+ import { homedir } from "node:os";
147833
148292
  import { join as join11 } from "node:path";
147834
- import { performance as performance7 } from "node:perf_hooks";
148293
+ var CODEX_AUTH_ENV = "CODEX_AUTH_JSON";
148294
+ function installCodexAuth() {
148295
+ const raw2 = process.env[CODEX_AUTH_ENV];
148296
+ if (!raw2) return null;
148297
+ const blob = parseCodexBlob(raw2);
148298
+ if (!blob) {
148299
+ log.warning(`\xBB ${CODEX_AUTH_ENV} present but malformed; ignoring`);
148300
+ return null;
148301
+ }
148302
+ const xdgDataHome = join11(homedir(), ".local", "share");
148303
+ const opencodeDir = join11(xdgDataHome, "opencode");
148304
+ const authPath = join11(opencodeDir, "auth.json");
148305
+ const opencodeAuth = {
148306
+ openai: {
148307
+ type: "oauth",
148308
+ refresh: blob.tokens.refresh_token,
148309
+ access: blob.tokens.access_token,
148310
+ // expires: 0 forces OpenCode's CodexAuthPlugin to refresh on first
148311
+ // request (it checks `expires < Date.now()`). safest default — we
148312
+ // don't carry an `expires_in` from the Codex blob.
148313
+ expires: 0,
148314
+ ...blob.tokens.account_id ? { accountId: blob.tokens.account_id } : {}
148315
+ }
148316
+ };
148317
+ mkdirSync5(opencodeDir, { recursive: true });
148318
+ writeFileSync8(authPath, `${JSON.stringify(opencodeAuth, null, 2)}
148319
+ `, { mode: 384 });
148320
+ log.info(`\xBB installed Codex auth at ${authPath}`);
148321
+ return { authPath, xdgDataHome, originalRefresh: blob.tokens.refresh_token };
148322
+ }
148323
+ function parseCodexBlob(raw2) {
148324
+ let parsed2;
148325
+ try {
148326
+ parsed2 = JSON.parse(raw2);
148327
+ } catch {
148328
+ return null;
148329
+ }
148330
+ if (!parsed2 || typeof parsed2 !== "object") return null;
148331
+ const v = parsed2;
148332
+ if (v.auth_mode !== "chatgpt") return null;
148333
+ const tokens = v.tokens;
148334
+ if (!tokens || typeof tokens !== "object") return null;
148335
+ const t = tokens;
148336
+ if (typeof t.access_token !== "string" || t.access_token.length === 0) return null;
148337
+ if (typeof t.refresh_token !== "string" || t.refresh_token.length === 0) return null;
148338
+ return {
148339
+ auth_mode: "chatgpt",
148340
+ tokens: {
148341
+ access_token: t.access_token,
148342
+ refresh_token: t.refresh_token,
148343
+ ...typeof t.id_token === "string" ? { id_token: t.id_token } : {},
148344
+ ...typeof t.account_id === "string" ? { account_id: t.account_id } : {}
148345
+ },
148346
+ ...typeof v.last_refresh === "string" ? { last_refresh: v.last_refresh } : {}
148347
+ };
148348
+ }
147835
148349
 
147836
148350
  // agents/opencodePlugin.ts
147837
148351
  var PULLFROG_BUS_EVENT_TYPE = "pullfrog_bus_event";
@@ -147914,6 +148428,9 @@ export default async function pullfrogEventsPlugin() {
147914
148428
  }
147915
148429
  `;
147916
148430
 
148431
+ // agents/opencodeShared.ts
148432
+ import { execFileSync as execFileSync4 } from "node:child_process";
148433
+
147917
148434
  // agents/subagentModels.ts
147918
148435
  function deriveSubagentModels(orchestratorSpec) {
147919
148436
  if (!orchestratorSpec) return { reviewer: void 0 };
@@ -147930,68 +148447,14 @@ function deriveSubagentModels(orchestratorSpec) {
147930
148447
  return { reviewer: void 0 };
147931
148448
  }
147932
148449
 
147933
- // agents/opencode.ts
147934
- async function installOpencodeCli() {
147935
- return await installFromNpmTarball({
147936
- packageName: "opencode-ai",
147937
- version: getDevDependencyVersion("opencode-ai"),
147938
- executablePath: "bin/opencode",
147939
- installDependencies: true
147940
- });
147941
- }
147942
- var GEMINI_3_DIRECT_THINKING_LEVEL = "medium";
147943
- var GEMINI_3_DIRECT_API_IDS = ["gemini-3.1-pro-preview", "gemini-3-flash-preview"];
147944
- function buildSecurityConfig(ctx, model) {
147945
- const config3 = {
147946
- permission: {
147947
- bash: "deny",
147948
- edit: "allow",
147949
- read: "allow",
147950
- webfetch: "allow",
147951
- external_directory: "allow",
147952
- skill: "allow"
147953
- },
147954
- mcp: {
147955
- [pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl }
147956
- },
147957
- agent: (() => {
147958
- const cfg = buildReviewerAgentConfig(model);
147959
- const reviewerModel = cfg[REVIEWER_AGENT_NAME]?.model ?? "(inherit)";
147960
- log.info(`\xBB subagent models: reviewfrog=${reviewerModel}`);
147961
- return cfg;
147962
- })(),
147963
- // opt into opencode's experimental `batch` tool (added in
147964
- // anomalyco/opencode PR #2983, opt-in via `experimental.batch_tool`). it
147965
- // exposes a single `batch` tool that runs 1-25 independent tool calls
147966
- // (read/grep/glob/bash/etc.) concurrently in one assistant turn, which
147967
- // collapses the dominant grep→20×read pattern into a single round trip.
147968
- // edits are explicitly disallowed inside the batch upstream. paired with
147969
- // the "Parallel tool execution" guidance in utils/instructions.ts so the
147970
- // model actually reaches for it. see wiki/prompt.md.
147971
- experimental: { batch_tool: true },
147972
- provider: {
147973
- google: {
147974
- models: Object.fromEntries(
147975
- GEMINI_3_DIRECT_API_IDS.map((id) => [
147976
- id,
147977
- {
147978
- options: {
147979
- thinkingConfig: { thinkingLevel: GEMINI_3_DIRECT_THINKING_LEVEL }
147980
- }
147981
- }
147982
- ])
147983
- )
147984
- }
147985
- }
147986
- };
147987
- if (model) {
147988
- config3.model = model;
147989
- const slashIndex = model.indexOf("/");
147990
- if (slashIndex > 0) {
147991
- config3.enabled_providers = [model.slice(0, slashIndex).toLowerCase()];
147992
- }
147993
- }
147994
- return JSON.stringify(config3);
148450
+ // agents/opencodeShared.ts
148451
+ function geminiHighThinkingOverrides() {
148452
+ return Object.fromEntries(
148453
+ modelAliases.filter((a) => a.provider === "google").map((a) => [
148454
+ a.resolve.replace(/^google\//, ""),
148455
+ { options: { thinkingConfig: { thinkingLevel: "high" } } }
148456
+ ])
148457
+ );
147995
148458
  }
147996
148459
  function buildReviewerAgentConfig(orchestratorModel) {
147997
148460
  const overrides = deriveSubagentModels(orchestratorModel);
@@ -148004,6 +148467,15 @@ function buildReviewerAgentConfig(orchestratorModel) {
148004
148467
  }
148005
148468
  };
148006
148469
  }
148470
+ async function installOpencodeCli(params) {
148471
+ return await installFromNpmTarball({
148472
+ packageName: "opencode-ai",
148473
+ version: getDevDependencyVersion("opencode-ai"),
148474
+ executablePath: params.binPath,
148475
+ installDependencies: true
148476
+ });
148477
+ }
148478
+ var AUTO_SELECT_WARNING = "select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this.";
148007
148479
  function getOpenCodeModels(cliPath) {
148008
148480
  try {
148009
148481
  const output = execFileSync4(cliPath, ["models"], {
@@ -148019,7 +148491,6 @@ function getOpenCodeModels(cliPath) {
148019
148491
  return [];
148020
148492
  }
148021
148493
  }
148022
- var AUTO_SELECT_WARNING = "select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this.";
148023
148494
  function autoSelectModel(cliPath) {
148024
148495
  const availableModels = getOpenCodeModels(cliPath);
148025
148496
  const availableSet = new Set(availableModels);
@@ -148040,6 +148511,58 @@ function autoSelectModel(cliPath) {
148040
148511
  log.warning(`\xBB no model resolved. letting OpenCode auto-select. ${AUTO_SELECT_WARNING}`);
148041
148512
  return void 0;
148042
148513
  }
148514
+
148515
+ // agents/opencode_v2.ts
148516
+ var installCli = () => installOpencodeCli({ binPath: "bin/opencode.exe" });
148517
+ function buildSecurityConfig(ctx, model) {
148518
+ const config3 = {
148519
+ permission: {
148520
+ bash: "deny",
148521
+ edit: "allow",
148522
+ read: "allow",
148523
+ webfetch: "allow",
148524
+ external_directory: "allow",
148525
+ skill: "allow"
148526
+ },
148527
+ mcp: {
148528
+ [pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl }
148529
+ },
148530
+ agent: (() => {
148531
+ const cfg = buildReviewerAgentConfig(model);
148532
+ const reviewerModel = cfg[REVIEWER_AGENT_NAME]?.model ?? "(inherit)";
148533
+ log.info(`\xBB subagent models: reviewfrog=${reviewerModel}`);
148534
+ return cfg;
148535
+ })(),
148536
+ // NB: `experimental.batch_tool: true` was opt-in at v1.4.x but is
148537
+ // declared-but-inert at v1.15.0 — the schema accepts it (`config/config.ts`)
148538
+ // and the SDK exposes the type, but no runtime call site reads it. removed
148539
+ // here to avoid carrying dead config; re-add when upstream wires the batch
148540
+ // tool back. see wiki/prompt.md and the v2 plan doc for the audit trail.
148541
+ //
148542
+ // gemini-3 thinking pinned to high for review depth; gpt and anthropic
148543
+ // effort set elsewhere (gpt: upstream default, anthropic: --effort flag in claude.ts).
148544
+ provider: { google: { models: geminiHighThinkingOverrides() } }
148545
+ };
148546
+ if (model) {
148547
+ config3.model = model;
148548
+ const slashIndex = model.indexOf("/");
148549
+ if (slashIndex > 0) {
148550
+ config3.enabled_providers = [model.slice(0, slashIndex).toLowerCase()];
148551
+ }
148552
+ }
148553
+ return JSON.stringify(config3);
148554
+ }
148555
+ function formatPartDuration(time4) {
148556
+ if (!time4 || typeof time4.start !== "number" || typeof time4.end !== "number") return "";
148557
+ if (time4.end <= time4.start) return "";
148558
+ return ` (${((time4.end - time4.start) / 1e3).toFixed(1)}s)`;
148559
+ }
148560
+ function terminalPayload(state) {
148561
+ if (!state) return void 0;
148562
+ if (state.status === "completed") return state.output;
148563
+ if (state.status === "error") return state.error;
148564
+ return void 0;
148565
+ }
148043
148566
  async function runOpenCode(params) {
148044
148567
  const startTime = performance7.now();
148045
148568
  let eventCount = 0;
@@ -148048,9 +148571,10 @@ async function runOpenCode(params) {
148048
148571
  let accumulatedCostUsd = 0;
148049
148572
  let tokensLogged = false;
148050
148573
  const toolCallTimings = /* @__PURE__ */ new Map();
148051
- let currentStepId = null;
148052
- let currentStepType = null;
148053
- let stepHistory = [];
148574
+ let lastEventAt = performance7.now();
148575
+ const recentStderr = [];
148576
+ let lastProviderError = null;
148577
+ let agentErrorEvent = null;
148054
148578
  const labeler = new SessionLabeler();
148055
148579
  function eventLabel(event) {
148056
148580
  const sid = event.sessionID ?? event.session_id;
@@ -148059,30 +148583,15 @@ async function runOpenCode(params) {
148059
148583
  function withLabel(label, message) {
148060
148584
  return label === ORCHESTRATOR_LABEL ? message : formatWithLabel(label, message);
148061
148585
  }
148062
- const thinkingTimers = /* @__PURE__ */ new Map();
148063
- function timerFor(label) {
148064
- let t = thinkingTimers.get(label);
148065
- if (!t) {
148066
- const formatLine = (line) => label === ORCHESTRATOR_LABEL ? line : formatWithLabel(label, line);
148067
- t = new ThinkingTimer(formatLine);
148068
- thinkingTimers.set(label, t);
148069
- }
148070
- return t;
148071
- }
148072
148586
  const taskDispatchByCallID = /* @__PURE__ */ new Map();
148073
- const pendingTaskDispatches = [];
148074
- const knownNonTaskCallIDs = /* @__PURE__ */ new Set();
148075
- function emitSubagentFinished(dispatch, status, output2, matchKind) {
148587
+ function emitSubagentFinished(dispatch, status, output2) {
148076
148588
  const subagentDuration = performance7.now() - dispatch.startedAt;
148077
148589
  const outputStr = typeof output2 === "string" ? output2 : "";
148078
148590
  const outputPreview = outputStr.length > 120 ? `${outputStr.slice(0, 120)}\u2026` : outputStr;
148079
- const matchSuffix = matchKind === "fifo" ? " [fifo-matched]" : "";
148080
148591
  log.info(
148081
- `\xBB subagent finished: ${dispatch.label} (${(subagentDuration / 1e3).toFixed(1)}s, status=${status})${matchSuffix}` + (outputPreview ? ` \u2014 ${outputPreview.replace(/\n/g, " ")}` : "")
148592
+ `\xBB subagent finished: ${dispatch.label} (${(subagentDuration / 1e3).toFixed(1)}s, status=${status})` + (outputPreview ? ` \u2014 ${outputPreview.replace(/\n/g, " ")}` : "")
148082
148593
  );
148083
148594
  taskDispatchByCallID.delete(dispatch.toolUseCallID);
148084
- const idx = pendingTaskDispatches.indexOf(dispatch);
148085
- if (idx >= 0) pendingTaskDispatches.splice(idx, 1);
148086
148595
  }
148087
148596
  function buildUsage() {
148088
148597
  const totalInput = accumulatedTokens.input + accumulatedTokens.cacheRead + accumulatedTokens.cacheWrite;
@@ -148096,55 +148605,6 @@ async function runOpenCode(params) {
148096
148605
  } : void 0;
148097
148606
  }
148098
148607
  const handlers2 = {
148099
- init: (event) => {
148100
- const label = labeler.labelFor(event.session_id ?? null);
148101
- log.debug(
148102
- withLabel(
148103
- label,
148104
- `\xBB ${params.label} init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}`
148105
- )
148106
- );
148107
- log.debug(withLabel(label, `\xBB ${params.label} init event (full): ${JSON.stringify(event)}`));
148108
- if (label === ORCHESTRATOR_LABEL) {
148109
- finalOutput = "";
148110
- accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
148111
- accumulatedCostUsd = 0;
148112
- tokensLogged = false;
148113
- } else {
148114
- log.info(`\xBB ${params.label} subagent init: ${label} (session ${event.session_id || "?"})`);
148115
- }
148116
- },
148117
- message: (event) => {
148118
- const label = eventLabel(event);
148119
- if (event.role === "assistant" && event.content?.trim()) {
148120
- const message = event.content.trim();
148121
- if (event.delta) {
148122
- log.debug(
148123
- withLabel(
148124
- label,
148125
- `\xBB ${params.label} thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}`
148126
- )
148127
- );
148128
- } else {
148129
- log.debug(
148130
- withLabel(
148131
- label,
148132
- `\xBB ${params.label} message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}`
148133
- )
148134
- );
148135
- if (label === ORCHESTRATOR_LABEL) {
148136
- finalOutput = message;
148137
- }
148138
- }
148139
- } else if (event.role === "user") {
148140
- log.debug(
148141
- withLabel(
148142
- label,
148143
- `\xBB ${params.label} message (${event.role}): ${event.content?.substring(0, 100) || ""}${event.content && event.content.length > 100 ? "..." : ""}`
148144
- )
148145
- );
148146
- }
148147
- },
148148
148608
  text: (event) => {
148149
148609
  if (event.part?.text?.trim()) {
148150
148610
  const message = event.part.text.trim();
@@ -148156,124 +148616,90 @@ async function runOpenCode(params) {
148156
148616
  }
148157
148617
  }
148158
148618
  },
148159
- step_start: (event) => {
148160
- const stepType = event.part?.type || "unknown";
148161
- const stepId = event.part?.id || "unknown";
148162
- currentStepId = stepId;
148163
- currentStepType = stepType;
148164
- stepHistory.push({ stepId, stepType, toolCalls: [] });
148619
+ /**
148620
+ * Reasoning blocks (only emitted when `--thinking` is set in baseArgs).
148621
+ * `part.time.{start,end}` give us a precise duration from opencode
148622
+ * itself. Not folded into `finalOutput` — that's the final answer,
148623
+ * not inner monologue.
148624
+ */
148625
+ reasoning: (event) => {
148626
+ const text = event.part?.text?.trim();
148627
+ if (!text) return;
148628
+ const label = eventLabel(event);
148629
+ const durationStr = formatPartDuration(event.part?.time);
148630
+ const preview = text.length > 280 ? `${text.slice(0, 280)}\u2026` : text;
148631
+ log.info(withLabel(label, `\xBB thinking${durationStr}: ${preview.replace(/\n+/g, " ")}`));
148632
+ if (text.length > 280) {
148633
+ log.debug(withLabel(label, `\xBB thinking (full): ${text}`));
148634
+ }
148635
+ },
148636
+ // step_start carries no information we surface today (token / cost are
148637
+ // reported on step_finish). explicit no-op so the dispatcher doesn't
148638
+ // log "unhandled event" for every step.
148639
+ step_start: () => {
148165
148640
  },
148166
- step_finish: async (event) => {
148167
- const stepId = event.part?.id || "unknown";
148168
- const eventTokens = event.part?.tokens;
148169
- if (eventTokens) {
148170
- accumulatedTokens.input += eventTokens.input || 0;
148171
- accumulatedTokens.output += eventTokens.output || 0;
148172
- accumulatedTokens.cacheRead += eventTokens.cache?.read || 0;
148173
- accumulatedTokens.cacheWrite += eventTokens.cache?.write || 0;
148641
+ step_finish: (event) => {
148642
+ const t = event.part?.tokens;
148643
+ if (t) {
148644
+ accumulatedTokens.input += t.input || 0;
148645
+ accumulatedTokens.output += t.output || 0;
148646
+ accumulatedTokens.cacheRead += t.cache?.read || 0;
148647
+ accumulatedTokens.cacheWrite += t.cache?.write || 0;
148174
148648
  }
148175
148649
  if (typeof event.part?.cost === "number" && Number.isFinite(event.part.cost)) {
148176
148650
  accumulatedCostUsd += event.part.cost;
148177
148651
  }
148178
- if (currentStepId === stepId) {
148179
- currentStepId = null;
148180
- currentStepType = null;
148181
- }
148182
148652
  },
148653
+ /**
148654
+ * Tool lifecycle event — at v1.15 a single event covers both completed
148655
+ * and error terminal states (read `part.state.status`). Subagent tool
148656
+ * parts arrive here via the bus-envelope re-emit too.
148657
+ */
148183
148658
  tool_use: (event) => {
148184
148659
  const toolName = event.part?.tool;
148185
148660
  const toolId = event.part?.callID;
148661
+ const state = event.part?.state;
148186
148662
  if (!toolName || !toolId) {
148187
148663
  log.info(
148188
148664
  `\xBB tool_use event missing toolName or toolId: ${JSON.stringify(event).substring(0, 500)}`
148189
148665
  );
148190
148666
  return;
148191
148667
  }
148192
- if (toolName === "task") {
148193
- if (!taskDispatchByCallID.has(toolId)) {
148194
- const taskInput = event.part?.state?.input ?? {};
148195
- const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
148196
- const dispatch = {
148197
- label: dispatchedLabel,
148198
- startedAt: performance7.now(),
148199
- toolUseCallID: toolId
148200
- };
148201
- taskDispatchByCallID.set(toolId, dispatch);
148202
- pendingTaskDispatches.push(dispatch);
148203
- log.info(
148204
- `\xBB dispatching subagent: ${dispatchedLabel}` + (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
148205
- );
148206
- }
148207
- } else {
148208
- knownNonTaskCallIDs.add(toolId);
148209
- }
148668
+ const status = state?.status;
148669
+ const isTerminal2 = status === "completed" || status === "error";
148210
148670
  const label = eventLabel(event);
148211
- if (stepHistory.length > 0) {
148212
- stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
148213
- }
148214
- if (params.onToolUse) {
148215
- params.onToolUse({
148216
- toolName,
148217
- input: event.part?.state?.input
148671
+ if (toolName === "task" && !taskDispatchByCallID.has(toolId)) {
148672
+ const taskInput = state?.input ?? {};
148673
+ const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
148674
+ taskDispatchByCallID.set(toolId, {
148675
+ label: dispatchedLabel,
148676
+ startedAt: performance7.now(),
148677
+ toolUseCallID: toolId
148218
148678
  });
148679
+ log.info(
148680
+ `\xBB dispatching subagent: ${dispatchedLabel}` + (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
148681
+ );
148219
148682
  }
148220
- timerFor(label).markToolCall();
148221
- const inputFormatted = formatJsonValue(event.part?.state?.input || {});
148222
- const toolCallLine = inputFormatted !== "{}" ? `\xBB ${toolName}(${inputFormatted})` : `\xBB ${toolName}()`;
148223
- log.info(withLabel(label, toolCallLine));
148224
- if (event.part?.state?.status === "completed" && event.part.state.output) {
148225
- log.debug(withLabel(label, ` output: ${event.part.state.output}`));
148683
+ params.onToolUse?.({ toolName, input: state?.input });
148684
+ if (!toolCallTimings.has(toolId)) {
148685
+ toolCallTimings.set(toolId, performance7.now());
148226
148686
  }
148227
- if (event.part?.state?.status === "error") {
148228
- const errorMsg = event.part.state.output ?? "(no error message)";
148229
- log.info(withLabel(label, `\xBB tool call failed: ${errorMsg}`));
148687
+ const inputFormatted = formatJsonValue(state?.input || {});
148688
+ const callLine = inputFormatted !== "{}" ? `\xBB ${toolName}(${inputFormatted})` : `\xBB ${toolName}()`;
148689
+ log.info(withLabel(label, callLine));
148690
+ if (state?.status === "completed") {
148691
+ log.debug(withLabel(label, ` output: ${state.output}`));
148230
148692
  }
148231
- if (toolName.includes("report_progress") && params.todoTracker) {
148232
- log.debug("\xBB report_progress detected, disabling todo tracking");
148233
- params.todoTracker.cancel();
148693
+ if (state?.status === "error") {
148694
+ log.info(withLabel(label, `\xBB tool call failed: ${state.error}`));
148234
148695
  }
148235
- if (toolName === "todowrite" && params.todoTracker?.enabled) {
148236
- params.todoTracker.update(event.part?.state?.input);
148237
- }
148238
- },
148239
- tool_result: (event) => {
148240
- const toolId = event.part?.callID || event.tool_id;
148241
- const status = event.part?.state?.status || event.status || "unknown";
148242
- const output2 = event.part?.state?.output || event.output;
148243
- const label = eventLabel(event);
148244
- timerFor(label).markToolResult();
148245
- if (taskDispatchByCallID.size > 0 || pendingTaskDispatches.length > 0) {
148246
- if (toolId && taskDispatchByCallID.has(toolId)) {
148247
- const dispatch = taskDispatchByCallID.get(toolId);
148248
- if (dispatch) emitSubagentFinished(dispatch, status, output2, "exact");
148249
- } else {
148250
- const callIDIsKnownNonTask = toolId ? knownNonTaskCallIDs.has(toolId) : false;
148251
- if (!callIDIsKnownNonTask && pendingTaskDispatches.length > 0) {
148252
- const dispatch = pendingTaskDispatches[0];
148253
- emitSubagentFinished(dispatch, status, output2, "fifo");
148254
- }
148255
- }
148256
- }
148257
- if (toolId) {
148696
+ if (isTerminal2) {
148697
+ const dispatch = toolName === "task" ? taskDispatchByCallID.get(toolId) : void 0;
148698
+ if (dispatch) emitSubagentFinished(dispatch, status, terminalPayload(state));
148258
148699
  const toolStartTime = toolCallTimings.get(toolId);
148259
- if (toolStartTime) {
148700
+ if (toolStartTime !== void 0) {
148260
148701
  const toolDuration = performance7.now() - toolStartTime;
148261
148702
  toolCallTimings.delete(toolId);
148262
- const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : "";
148263
- log.debug(
148264
- withLabel(
148265
- label,
148266
- `\xBB ${params.label} tool_result${stepContext}: id=${toolId}, status=${status}, duration=${Math.round(toolDuration)}ms`
148267
- )
148268
- );
148269
- if (output2) {
148270
- log.debug(
148271
- withLabel(
148272
- label,
148273
- ` output: ${typeof output2 === "string" ? output2 : JSON.stringify(output2)}`
148274
- )
148275
- );
148276
- }
148277
148703
  if (toolDuration > 5e3) {
148278
148704
  log.info(
148279
148705
  withLabel(
@@ -148284,12 +148710,12 @@ async function runOpenCode(params) {
148284
148710
  }
148285
148711
  }
148286
148712
  }
148287
- if (status === "error") {
148288
- const errorMsg = typeof output2 === "string" ? output2 : JSON.stringify(output2);
148289
- log.info(withLabel(label, `\xBB tool call failed: ${errorMsg}`));
148290
- } else if (output2) {
148291
- const outputStr = typeof output2 === "string" ? output2 : JSON.stringify(output2);
148292
- log.debug(withLabel(label, `tool output: ${outputStr}`));
148713
+ if (toolName.includes("report_progress") && params.todoTracker) {
148714
+ log.debug("\xBB report_progress detected, disabling todo tracking");
148715
+ params.todoTracker.cancel();
148716
+ }
148717
+ if (toolName === "todowrite" && params.todoTracker?.enabled && isTerminal2) {
148718
+ params.todoTracker.update(state?.input);
148293
148719
  }
148294
148720
  },
148295
148721
  error: (event) => {
@@ -148298,23 +148724,18 @@ async function runOpenCode(params) {
148298
148724
  const errorMessage = event.error?.data?.message || event.error?.name || JSON.stringify(event);
148299
148725
  log.info(`\xBB ${params.label} error event: ${errorName}: ${errorMessage}`);
148300
148726
  },
148301
- result: async (event) => {
148302
- const status = event.status || "unknown";
148303
- const duration4 = event.stats?.duration_ms || 0;
148304
- const toolCalls = event.stats?.tool_calls || 0;
148305
- log.info(
148306
- `\xBB ${params.label} result: status=${status}, duration=${duration4}ms, tool_calls=${toolCalls}`
148307
- );
148308
- if (event.status === "error") {
148309
- log.info(`\xBB ${params.label} failed: ${JSON.stringify(event)}`);
148310
- } else {
148311
- log.info(`\xBB run complete: tool_calls=${toolCalls}, duration=${duration4}ms`);
148312
- if ((accumulatedTokens.input > 0 || accumulatedTokens.output > 0 || accumulatedTokens.cacheRead > 0 || accumulatedTokens.cacheWrite > 0) && !tokensLogged) {
148313
- logTokenTable({ ...accumulatedTokens, costUsd: accumulatedCostUsd });
148314
- tokensLogged = true;
148315
- }
148316
- }
148317
- },
148727
+ /**
148728
+ * Bus envelope (re-emitted by `opencodePlugin.ts`). Synthesizes a
148729
+ * CLI-style event for each part type and routes it through the
148730
+ * orchestrator's handlers same labeling / attribution / logging path.
148731
+ * Mirrors the dispatch in upstream's `cli/cmd/run.ts` `loop()`.
148732
+ *
148733
+ * NOT routed: subagent `step-start` / `step-finish`. step_finish carries
148734
+ * `tokens` and `cost` that the orchestrator's handler folds into run-wide
148735
+ * accumulators double-counting subagent tokens would inflate usage
148736
+ * telemetry. text/tool_use already gate on ORCHESTRATOR_LABEL inside their
148737
+ * handlers for the same reason.
148738
+ */
148318
148739
  [PULLFROG_BUS_EVENT_TYPE]: async (event) => {
148319
148740
  const busEvent = event.bus_event;
148320
148741
  if (!busEvent || busEvent.type !== "message.part.updated") return;
@@ -148324,20 +148745,15 @@ async function runOpenCode(params) {
148324
148745
  const partType = part.type;
148325
148746
  if (partType === "tool") {
148326
148747
  const status = part.state?.status;
148327
- const partWithToolFields = part;
148328
- const isOrchestratorTaskDispatch = partWithToolFields.tool === "task" && status === "running";
148329
- if (isOrchestratorTaskDispatch) {
148330
- const callID = partWithToolFields.callID;
148331
- if (typeof callID === "string" && !taskDispatchByCallID.has(callID)) {
148332
- const taskInput = partWithToolFields.state?.input ?? {};
148748
+ if (part.tool === "task" && status === "running" && part.callID) {
148749
+ if (!taskDispatchByCallID.has(part.callID)) {
148750
+ const taskInput = part.state?.input ?? {};
148333
148751
  const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
148334
- const dispatch = {
148752
+ taskDispatchByCallID.set(part.callID, {
148335
148753
  label: dispatchedLabel,
148336
148754
  startedAt: performance7.now(),
148337
- toolUseCallID: callID
148338
- };
148339
- taskDispatchByCallID.set(callID, dispatch);
148340
- pendingTaskDispatches.push(dispatch);
148755
+ toolUseCallID: part.callID
148756
+ });
148341
148757
  log.info(
148342
148758
  `\xBB dispatching subagent: ${dispatchedLabel}` + (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
148343
148759
  );
@@ -148345,27 +148761,26 @@ async function runOpenCode(params) {
148345
148761
  return;
148346
148762
  }
148347
148763
  if (status !== "completed" && status !== "error") return;
148348
- await handlers2.tool_use({
148349
- type: "tool_use",
148350
- sessionID,
148351
- part
148352
- });
148764
+ await handlers2.tool_use({ type: "tool_use", sessionID, part });
148353
148765
  return;
148354
148766
  }
148355
148767
  if (partType === "step-start" || partType === "step-finish") return;
148356
148768
  if (partType === "text" && part.time?.end !== void 0) {
148357
- await handlers2.text({
148358
- type: "text",
148359
- sessionID,
148360
- part
148361
- });
148769
+ handlers2.text({ type: "text", sessionID, part });
148362
148770
  return;
148363
148771
  }
148772
+ if (partType === "reasoning" && part.time?.end !== void 0) {
148773
+ handlers2.reasoning({ type: "reasoning", sessionID, part });
148774
+ }
148364
148775
  }
148365
148776
  };
148366
- const recentStderr = [];
148367
- let lastProviderError = null;
148368
- let agentErrorEvent = null;
148777
+ const diagnostic = {
148778
+ label: params.label,
148779
+ recentStderr,
148780
+ lastProviderError: void 0,
148781
+ eventCount: 0
148782
+ };
148783
+ params.toolState.agentDiagnostic = diagnostic;
148369
148784
  const output = new TailBuffer(DEFAULT_MAX_RETAINED_BYTES);
148370
148785
  let stdoutBuffer = "";
148371
148786
  try {
@@ -148414,16 +148829,17 @@ async function runOpenCode(params) {
148414
148829
  continue;
148415
148830
  }
148416
148831
  eventCount++;
148832
+ diagnostic.eventCount = eventCount;
148417
148833
  log.debug(JSON.stringify(event, null, 2));
148418
- const timeSinceLastActivity = getIdleMs();
148419
- if (timeSinceLastActivity > 1e4) {
148834
+ const idleMs = performance7.now() - lastEventAt;
148835
+ if (idleMs > 1e4) {
148420
148836
  const activeToolCalls = toolCallTimings.size;
148421
148837
  const toolCallInfo = activeToolCalls > 0 ? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})` : ` (${params.label} may be processing internally - LLM calls, planning, etc.)`;
148422
148838
  log.info(
148423
- `\xBB no activity for ${(timeSinceLastActivity / 1e3).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)`
148839
+ `\xBB no activity for ${(idleMs / 1e3).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)`
148424
148840
  );
148425
148841
  }
148426
- markActivity();
148842
+ lastEventAt = performance7.now();
148427
148843
  const handler2 = handlers2[event.type];
148428
148844
  if (!handler2) {
148429
148845
  log.info(
@@ -148445,10 +148861,11 @@ async function runOpenCode(params) {
148445
148861
  if (!trimmed) return;
148446
148862
  recentStderr.push(trimmed);
148447
148863
  if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
148448
- const providerError = detectProviderError(trimmed);
148449
- if (providerError) {
148450
- lastProviderError = providerError;
148451
- log.info(`\xBB provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
148864
+ const match3 = findProviderErrorMatch(trimmed);
148865
+ if (match3) {
148866
+ lastProviderError = match3.label;
148867
+ diagnostic.lastProviderError = match3.label;
148868
+ log.info(`\xBB provider error detected (${match3.label}): ${match3.excerpt}`);
148452
148869
  } else {
148453
148870
  log.debug(trimmed);
148454
148871
  }
@@ -148459,14 +148876,13 @@ async function runOpenCode(params) {
148459
148876
  } else {
148460
148877
  params.todoTracker?.cancel();
148461
148878
  }
148462
- if (pendingTaskDispatches.length > 0) {
148463
- for (const dispatch of [...pendingTaskDispatches]) {
148879
+ if (taskDispatchByCallID.size > 0) {
148880
+ for (const dispatch of taskDispatchByCallID.values()) {
148464
148881
  const elapsed = performance7.now() - dispatch.startedAt;
148465
148882
  log.info(
148466
- `\xBB subagent finished (inferred at run-end): ${dispatch.label} (\u2264${(elapsed / 1e3).toFixed(1)}s) \u2014 no matching tool_result observed; subagent reply likely arrived via assistant message`
148883
+ `\xBB subagent finished (inferred at run-end): ${dispatch.label} (\u2264${(elapsed / 1e3).toFixed(1)}s) \u2014 no terminal tool_use observed; reply likely arrived via assistant message`
148467
148884
  );
148468
148885
  }
148469
- pendingTaskDispatches.length = 0;
148470
148886
  taskDispatchByCallID.clear();
148471
148887
  }
148472
148888
  const duration4 = performance7.now() - startTime;
@@ -148538,32 +148954,33 @@ ${stderrContext}`);
148538
148954
  `\xBB recent stderr (last ${Math.min(recentStderr.length, 10)} lines):
148539
148955
  ${stderrContext}`
148540
148956
  );
148957
+ const body = formatAgentHangBody({ diagnostic, isHang: isActivityTimeout, errorMessage });
148541
148958
  return {
148542
148959
  success: false,
148543
148960
  output: finalOutput || output.toString(),
148544
- error: `${errorMessage} [${diagnosis}]`,
148961
+ error: body ?? `${errorMessage} [${diagnosis}]`,
148545
148962
  usage: buildUsage()
148546
148963
  };
148547
148964
  }
148548
148965
  }
148549
148966
  var opencode = agent({
148550
148967
  name: "opencode",
148551
- install: installOpencodeCli,
148968
+ install: installCli,
148552
148969
  run: async (ctx) => {
148553
- const cliPath = await installOpencodeCli();
148970
+ const cliPath = await installCli();
148554
148971
  const rawModel = ctx.payload.proxyModel ?? ctx.resolvedModel ?? autoSelectModel(cliPath);
148555
148972
  const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
148556
148973
  const isBedrockRoute = rawModel !== void 0 && bedrockModelId !== void 0 && bedrockModelId === rawModel;
148557
148974
  const model = isBedrockRoute ? `amazon-bedrock/${rawModel}` : rawModel;
148558
148975
  const homeEnv = {
148559
148976
  HOME: ctx.tmpdir,
148560
- XDG_CONFIG_HOME: join11(ctx.tmpdir, ".config")
148977
+ XDG_CONFIG_HOME: join12(ctx.tmpdir, ".config")
148561
148978
  };
148562
- mkdirSync5(join11(homeEnv.XDG_CONFIG_HOME, "opencode"), { recursive: true });
148563
- const opencodePluginDir = join11(homeEnv.XDG_CONFIG_HOME, "opencode", "plugin");
148564
- mkdirSync5(opencodePluginDir, { recursive: true });
148565
- writeFileSync8(
148566
- join11(opencodePluginDir, PULLFROG_OPENCODE_PLUGIN_FILENAME),
148979
+ mkdirSync6(join12(homeEnv.XDG_CONFIG_HOME, "opencode"), { recursive: true });
148980
+ const opencodePluginDir = join12(homeEnv.XDG_CONFIG_HOME, "opencode", "plugin");
148981
+ mkdirSync6(opencodePluginDir, { recursive: true });
148982
+ writeFileSync9(
148983
+ join12(opencodePluginDir, PULLFROG_OPENCODE_PLUGIN_FILENAME),
148567
148984
  PULLFROG_OPENCODE_PLUGIN_SOURCE
148568
148985
  );
148569
148986
  const agentBrowserVersion = getDevDependencyVersion("agent-browser");
@@ -148574,18 +148991,32 @@ var opencode = agent({
148574
148991
  agent: "opencode"
148575
148992
  });
148576
148993
  installBundledSkills({ home: homeEnv.HOME });
148577
- const baseArgs = ["run", "--format", "json", "--print-logs"];
148994
+ const codexAuth = installCodexAuth();
148995
+ const baseArgs = ["run", "--format", "json", "--print-logs", "--thinking"];
148578
148996
  const permissionOverride = JSON.stringify({
148579
148997
  external_directory: { "*": "deny", "/tmp/*": "allow" }
148580
148998
  });
148999
+ const repoDir = process.cwd();
148581
149000
  const env2 = {
148582
149001
  ...process.env,
148583
149002
  ...homeEnv,
149003
+ PWD: repoDir,
148584
149004
  OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
148585
149005
  OPENCODE_PERMISSION: permissionOverride,
148586
149006
  GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY
148587
149007
  };
148588
- const repoDir = process.cwd();
149008
+ if (codexAuth) {
149009
+ env2.XDG_DATA_HOME = codexAuth.xdgDataHome;
149010
+ delete env2.OPENAI_API_KEY;
149011
+ core2.saveState(
149012
+ "codex_writeback",
149013
+ JSON.stringify({
149014
+ apiToken: ctx.apiToken,
149015
+ authPath: codexAuth.authPath,
149016
+ originalRefresh: codexAuth.originalRefresh
149017
+ })
149018
+ );
149019
+ }
148589
149020
  log.debug(`\xBB starting Pullfrog (OpenCode): ${cliPath} ${baseArgs.join(" ")}`);
148590
149021
  log.debug(`\xBB working directory: ${repoDir}`);
148591
149022
  const runParams = {
@@ -148593,6 +149024,7 @@ var opencode = agent({
148593
149024
  cliPath,
148594
149025
  cwd: repoDir,
148595
149026
  env: env2,
149027
+ toolState: ctx.toolState,
148596
149028
  todoTracker: ctx.todoTracker,
148597
149029
  onActivityTimeout: ctx.onActivityTimeout,
148598
149030
  onToolUse: ctx.onToolUse
@@ -148605,7 +149037,7 @@ var opencode = agent({
148605
149037
  ctx,
148606
149038
  initialResult: result,
148607
149039
  initialUsage: result.usage,
148608
- reflectionPrompt: ctx.toolState.learningsFilePath ? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath) : void 0,
149040
+ reflectionPrompt: ctx.toolState.learningsFilePath && shouldRunReflection(ctx.toolState.selectedMode) ? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath) : void 0,
148609
149041
  resume: async (c) => runOpenCode({
148610
149042
  ...runParams,
148611
149043
  args: [...baseArgs, "--continue", c.prompt]
@@ -148679,7 +149111,9 @@ function resolveAgent(ctx) {
148679
149111
  }
148680
149112
 
148681
149113
  // utils/apiKeys.ts
148682
- var knownApiKeys = new Set(Object.values(providers).flatMap((p) => [...p.envVars]));
149114
+ var knownApiKeys = new Set(
149115
+ Object.values(providers).flatMap((p) => [...p.envVars, ...p.managedCredentials ?? []])
149116
+ );
148683
149117
  var MISSING_KEY_MARKER = "no API key found";
148684
149118
  function buildMissingApiKeyError(params) {
148685
149119
  const githubSecretsUrl = `https://github.com/${params.owner}/${params.name}/settings/secrets/actions`;
@@ -148708,6 +149142,11 @@ function hasEnvVar2(name) {
148708
149142
  const value2 = process.env[name];
148709
149143
  return typeof value2 === "string" && value2.length > 0;
148710
149144
  }
149145
+ function hasProviderKey(model) {
149146
+ const requiredVars = getModelEnvVars(model);
149147
+ if (requiredVars.length === 0) return true;
149148
+ return requiredVars.some((v) => hasEnvVar2(v));
149149
+ }
148711
149150
  function validateBedrockSetup(params) {
148712
149151
  const hasAuth = hasEnvVar2("AWS_BEARER_TOKEN_BEDROCK") || hasEnvVar2("AWS_ACCESS_KEY_ID") && hasEnvVar2("AWS_SECRET_ACCESS_KEY");
148713
149152
  const missing = [];
@@ -148742,7 +149181,7 @@ function validateAgentApiKey(params) {
148742
149181
  }
148743
149182
  function isApiKeyAuthError(text) {
148744
149183
  if (!text) return false;
148745
- return text.includes(MISSING_KEY_MARKER) || /Invalid API key/i.test(text) || /\bUser not found\b/i.test(text) || /\bInvalid authentication\b/i.test(text);
149184
+ return text.includes(MISSING_KEY_MARKER) || /Invalid API key/i.test(text) || /\bUser not found\b/i.test(text) || /\bInvalid authentication\b/i.test(text) || /authentication_error/i.test(text) || /Invalid bearer token/i.test(text) || /api_error_status\s*=\s*401/i.test(text) || /API Error:\s*401/i.test(text);
148746
149185
  }
148747
149186
  function formatApiKeyErrorSummary(params) {
148748
149187
  if (params.raw.includes(MISSING_KEY_MARKER)) {
@@ -148854,11 +149293,137 @@ async function fetchBodyHtml(ctx) {
148854
149293
  }
148855
149294
  }
148856
149295
 
149296
+ // utils/byokFallback.ts
149297
+ var FREE_FALLBACK_SLUG = "opencode/minimax-m2.5-free";
149298
+ function selectFallbackModelIfNeeded(input) {
149299
+ if (input.proxyModel) return { fallback: false };
149300
+ if (!input.resolvedModel) return { fallback: false };
149301
+ if (input.resolvedModel === FREE_FALLBACK_SLUG) return { fallback: false };
149302
+ if (!input.resolvedModel.includes("/")) return { fallback: false };
149303
+ if (hasProviderKey(input.resolvedModel)) return { fallback: false };
149304
+ return {
149305
+ fallback: true,
149306
+ from: input.resolvedModel,
149307
+ to: FREE_FALLBACK_SLUG
149308
+ };
149309
+ }
149310
+
149311
+ // utils/gitAuthServer.ts
149312
+ import { randomUUID as randomUUID3 } from "node:crypto";
149313
+ import { writeFileSync as writeFileSync10 } from "node:fs";
149314
+ import { createServer as createServer2 } from "node:http";
149315
+ import { join as join13 } from "node:path";
149316
+ var CODE_TTL_MS = 5 * 60 * 1e3;
149317
+ var TAMPER_WINDOW_MS = 6e4;
149318
+ function revokeGitHubToken(token) {
149319
+ fetch("https://api.github.com/installation/token", {
149320
+ method: "DELETE",
149321
+ headers: {
149322
+ Authorization: `Bearer ${token}`,
149323
+ Accept: "application/vnd.github+json",
149324
+ "User-Agent": "pullfrog"
149325
+ }
149326
+ }).then(
149327
+ (r) => log.info(`token revocation response: ${r.status}`),
149328
+ () => log.warning("token revocation request failed")
149329
+ );
149330
+ }
149331
+ async function startGitAuthServer(tmpdir3) {
149332
+ const codes = /* @__PURE__ */ new Map();
149333
+ const server = createServer2((req, res) => {
149334
+ if (req.method !== "GET") {
149335
+ res.writeHead(405).end();
149336
+ return;
149337
+ }
149338
+ const code = req.url?.slice(1);
149339
+ if (!code) {
149340
+ res.writeHead(400).end();
149341
+ return;
149342
+ }
149343
+ const entry = codes.get(code);
149344
+ if (!entry) {
149345
+ res.writeHead(404).end();
149346
+ return;
149347
+ }
149348
+ if (entry.state === "pending") {
149349
+ entry.state = "consumed";
149350
+ clearTimeout(entry.timeout);
149351
+ entry.timeout = setTimeout(() => codes.delete(code), TAMPER_WINDOW_MS);
149352
+ entry.timeout.unref();
149353
+ res.writeHead(200, { "Content-Type": "text/plain" });
149354
+ res.end(entry.token);
149355
+ return;
149356
+ }
149357
+ log.info("askpass code used twice \u2014 revoking token");
149358
+ revokeGitHubToken(entry.token);
149359
+ clearTimeout(entry.timeout);
149360
+ codes.delete(code);
149361
+ res.writeHead(409, { "Content-Type": "text/plain" });
149362
+ res.end("compromised");
149363
+ });
149364
+ await new Promise((resolve3, reject) => {
149365
+ server.on("error", reject);
149366
+ server.listen(0, "127.0.0.1", () => resolve3());
149367
+ });
149368
+ const rawAddr = server.address();
149369
+ if (!rawAddr || typeof rawAddr === "string") {
149370
+ throw new Error("git auth server failed to bind");
149371
+ }
149372
+ const port = rawAddr.port;
149373
+ log.debug(`git auth server listening on 127.0.0.1:${port}`);
149374
+ function register4(token) {
149375
+ const code = randomUUID3();
149376
+ const timeout = setTimeout(() => {
149377
+ codes.delete(code);
149378
+ log.debug(`git auth code expired: ${code.slice(0, 8)}...`);
149379
+ }, CODE_TTL_MS);
149380
+ timeout.unref();
149381
+ codes.set(code, { token, state: "pending", timeout });
149382
+ return code;
149383
+ }
149384
+ function writeAskpassScript(code) {
149385
+ const scriptId = randomUUID3();
149386
+ const scriptName = `askpass-${scriptId}.js`;
149387
+ const scriptPath = join13(tmpdir3, scriptName);
149388
+ const content = [
149389
+ `#!/usr/bin/env node`,
149390
+ `var a=process.argv[2]||"";`,
149391
+ `if(/^Username/i.test(a)){process.stdout.write("x-access-token\\n")}`,
149392
+ `else{var h=require("http");`,
149393
+ `h.get("http://127.0.0.1:${port}/${code}",function(r){`,
149394
+ `if(r.statusCode===409){process.stderr.write("askpass-compromised\\n");process.exit(1)}`,
149395
+ `if(r.statusCode!==200){process.exit(1)}`,
149396
+ `var d="";r.on("data",function(c){d+=c});`,
149397
+ `r.on("end",function(){`,
149398
+ `process.stdout.write(d+"\\n");`,
149399
+ `try{require("fs").unlinkSync("${scriptPath.replace(/\\/g, "\\\\")}")}catch(e){}`,
149400
+ `})}).on("error",function(){process.exit(1)})}`
149401
+ ].join("\n");
149402
+ writeFileSync10(scriptPath, content, { mode: 448 });
149403
+ return scriptPath;
149404
+ }
149405
+ async function close() {
149406
+ for (const entry of codes.values()) {
149407
+ clearTimeout(entry.timeout);
149408
+ }
149409
+ codes.clear();
149410
+ await new Promise((resolve3) => server.close(() => resolve3()));
149411
+ log.debug("git auth server closed");
149412
+ }
149413
+ return {
149414
+ port,
149415
+ register: register4,
149416
+ writeAskpassScript,
149417
+ close,
149418
+ [Symbol.asyncDispose]: close
149419
+ };
149420
+ }
149421
+
148857
149422
  // utils/github.ts
148858
- var core2 = __toESM(require_core(), 1);
149423
+ var core3 = __toESM(require_core(), 1);
148859
149424
  import { createSign } from "node:crypto";
148860
149425
  import { rename, writeFile } from "node:fs/promises";
148861
- import { dirname as dirname3, join as join12 } from "node:path";
149426
+ import { dirname as dirname3, join as join14 } from "node:path";
148862
149427
 
148863
149428
  // node_modules/.pnpm/@octokit+plugin-throttling@11.0.3_@octokit+core@7.0.5/node_modules/@octokit/plugin-throttling/dist-bundle/index.js
148864
149429
  var import_light = __toESM(require_light(), 1);
@@ -152517,7 +153082,7 @@ var TokenExchangeError = class extends Error {
152517
153082
  }
152518
153083
  };
152519
153084
  async function acquireTokenViaOIDC(opts) {
152520
- const oidcToken = await core2.getIDToken("pullfrog-api");
153085
+ const oidcToken = await core3.getIDToken("pullfrog-api");
152521
153086
  const repos = [...opts?.repos ?? []];
152522
153087
  const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
152523
153088
  if (targetRepo) {
@@ -152675,9 +153240,13 @@ async function acquireNewToken(opts) {
152675
153240
  return error49 instanceof Error && (error49.message.includes("timed out") || error49.message.includes("fetch failed") || error49.message.includes("ECONNRESET") || error49.message.includes("ETIMEDOUT"));
152676
153241
  }
152677
153242
  });
152678
- } else {
152679
- return await acquireTokenViaGitHubApp(opts);
152680
153243
  }
153244
+ if (process.env.GITHUB_ACTIONS === "true") {
153245
+ throw new Error(
153246
+ "missing `permissions: id-token: write` on the Pullfrog workflow job.\n\nPullfrog mints short-lived GitHub App installation tokens via OIDC and\nrequires `id-token: write` to be granted at the job level. add the\nfollowing to your workflow yaml:\n\n jobs:\n pullfrog:\n permissions:\n id-token: write # mint Pullfrog installation tokens via OIDC\n contents: read # for actions/checkout\n\nsee https://docs.pullfrog.com/headless-action#required-permissions for the full template."
153247
+ );
153248
+ }
153249
+ return await acquireTokenViaGitHubApp(opts);
152681
153250
  }
152682
153251
  function parseRepoContext() {
152683
153252
  const githubRepo = process.env.GITHUB_REPOSITORY;
@@ -152712,7 +153281,7 @@ function getGitHubUsageSummary() {
152712
153281
  }
152713
153282
  async function writeGitHubUsageSummaryToFile(path3) {
152714
153283
  const summary2 = getGitHubUsageSummary();
152715
- const tmpPath = join12(dirname3(path3), `.usage-summary-${process.pid}.tmp`);
153284
+ const tmpPath = join14(dirname3(path3), `.usage-summary-${process.pid}.tmp`);
152716
153285
  await writeFile(tmpPath, JSON.stringify(summary2));
152717
153286
  await rename(tmpPath, path3);
152718
153287
  }
@@ -152762,253 +153331,6 @@ function createOctokit(token) {
152762
153331
  return octokit;
152763
153332
  }
152764
153333
 
152765
- // utils/token.ts
152766
- var core3 = __toESM(require_core(), 1);
152767
- import assert2 from "node:assert/strict";
152768
- var mcpTokenValue;
152769
- function getJobToken() {
152770
- const inputToken = core3.getInput("token");
152771
- if (inputToken) {
152772
- return inputToken;
152773
- }
152774
- const fallbackToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
152775
- if (fallbackToken) {
152776
- return fallbackToken;
152777
- }
152778
- throw new Error("token input is required");
152779
- }
152780
- async function resolveTokens(params) {
152781
- assert2(!mcpTokenValue, "tokens are already resolved");
152782
- const externalToken = process.env.GH_TOKEN;
152783
- if (externalToken) {
152784
- mcpTokenValue = externalToken;
152785
- if (isGitHubActions) {
152786
- core3.setSecret(externalToken);
152787
- }
152788
- log.info("\xBB using external GH_TOKEN for both git and MCP");
152789
- return {
152790
- gitToken: externalToken,
152791
- mcpToken: externalToken,
152792
- async [Symbol.asyncDispose]() {
152793
- mcpTokenValue = void 0;
152794
- }
152795
- };
152796
- }
152797
- const gitPermissions = params.push === "disabled" ? { contents: "read" } : { contents: "write", workflows: "write" };
152798
- const gitToken = await acquireNewToken({ permissions: gitPermissions });
152799
- if (isGitHubActions) {
152800
- core3.setSecret(gitToken);
152801
- }
152802
- log.info(
152803
- `\xBB acquired git token (${Object.entries(gitPermissions).map((e) => e.join(":")).join(", ")})`
152804
- );
152805
- const mcpPermissions = {
152806
- contents: "write",
152807
- pull_requests: "write",
152808
- issues: "write",
152809
- checks: "read",
152810
- actions: "read"
152811
- };
152812
- const mcpToken = await acquireNewToken({ permissions: mcpPermissions });
152813
- if (isGitHubActions) {
152814
- core3.setSecret(mcpToken);
152815
- }
152816
- log.info(
152817
- `\xBB acquired scoped MCP token (${Object.entries(mcpPermissions).map((e) => e.join(":")).join(", ")})`
152818
- );
152819
- mcpTokenValue = mcpToken;
152820
- let disposingRef;
152821
- const dispose = async () => {
152822
- if (disposingRef) {
152823
- return disposingRef.promise;
152824
- }
152825
- disposingRef = Promise.withResolvers();
152826
- try {
152827
- mcpTokenValue = void 0;
152828
- await Promise.all([
152829
- revokeGitHubInstallationToken(gitToken),
152830
- revokeGitHubInstallationToken(mcpToken)
152831
- ]);
152832
- } finally {
152833
- removeSignalHandler();
152834
- disposingRef.resolve();
152835
- disposingRef = void 0;
152836
- }
152837
- };
152838
- const removeSignalHandler = onExitSignal(dispose);
152839
- return {
152840
- gitToken,
152841
- mcpToken,
152842
- [Symbol.asyncDispose]: dispose
152843
- };
152844
- }
152845
- function getGitHubInstallationToken() {
152846
- assert2(mcpTokenValue, "tokens not set. call resolveTokens first.");
152847
- return mcpTokenValue;
152848
- }
152849
- async function revokeGitHubInstallationToken(token) {
152850
- const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
152851
- try {
152852
- await fetch(`${apiUrl}/installation/token`, {
152853
- method: "DELETE",
152854
- headers: {
152855
- Accept: "application/vnd.github+json",
152856
- Authorization: `Bearer ${token}`,
152857
- "X-GitHub-Api-Version": "2022-11-28"
152858
- }
152859
- });
152860
- log.debug("\xBB installation token revoked");
152861
- } catch (error49) {
152862
- log.info(
152863
- `Failed to revoke installation token: ${error49 instanceof Error ? error49.message : String(error49)}`
152864
- );
152865
- }
152866
- }
152867
-
152868
- // utils/errorReport.ts
152869
- async function reportErrorToComment(ctx) {
152870
- const formattedError = ctx.title ? `${ctx.title}
152871
-
152872
- ${ctx.error}` : ctx.error;
152873
- const comment = ctx.toolState.progressComment;
152874
- if (!comment) {
152875
- return;
152876
- }
152877
- const repoContext = parseRepoContext();
152878
- const octokit = createOctokit(getGitHubInstallationToken());
152879
- const runId = process.env.GITHUB_RUN_ID ? Number.parseInt(process.env.GITHUB_RUN_ID, 10) : void 0;
152880
- const customParts = [];
152881
- if (runId) {
152882
- const apiUrl = getApiUrl();
152883
- customParts.push(
152884
- `[Rerun failed job \u2794](${apiUrl}/trigger/${repoContext.owner}/${repoContext.name}/${runId}?action=rerun)`
152885
- );
152886
- }
152887
- const footer = buildPullfrogFooter({
152888
- triggeredBy: true,
152889
- workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : void 0,
152890
- customParts,
152891
- model: ctx.toolState.model
152892
- });
152893
- await updateProgressComment(
152894
- { octokit, owner: repoContext.owner, repo: repoContext.name },
152895
- comment,
152896
- `${formattedError}${footer}`
152897
- );
152898
- ctx.toolState.wasUpdated = true;
152899
- }
152900
-
152901
- // utils/gitAuthServer.ts
152902
- import { randomUUID as randomUUID3 } from "node:crypto";
152903
- import { writeFileSync as writeFileSync9 } from "node:fs";
152904
- import { createServer as createServer2 } from "node:http";
152905
- import { join as join13 } from "node:path";
152906
- var CODE_TTL_MS = 5 * 60 * 1e3;
152907
- var TAMPER_WINDOW_MS = 6e4;
152908
- function revokeGitHubToken(token) {
152909
- fetch("https://api.github.com/installation/token", {
152910
- method: "DELETE",
152911
- headers: {
152912
- Authorization: `Bearer ${token}`,
152913
- Accept: "application/vnd.github+json",
152914
- "User-Agent": "pullfrog"
152915
- }
152916
- }).then(
152917
- (r) => log.info(`token revocation response: ${r.status}`),
152918
- () => log.warning("token revocation request failed")
152919
- );
152920
- }
152921
- async function startGitAuthServer(tmpdir3) {
152922
- const codes = /* @__PURE__ */ new Map();
152923
- const server = createServer2((req, res) => {
152924
- if (req.method !== "GET") {
152925
- res.writeHead(405).end();
152926
- return;
152927
- }
152928
- const code = req.url?.slice(1);
152929
- if (!code) {
152930
- res.writeHead(400).end();
152931
- return;
152932
- }
152933
- const entry = codes.get(code);
152934
- if (!entry) {
152935
- res.writeHead(404).end();
152936
- return;
152937
- }
152938
- if (entry.state === "pending") {
152939
- entry.state = "consumed";
152940
- clearTimeout(entry.timeout);
152941
- entry.timeout = setTimeout(() => codes.delete(code), TAMPER_WINDOW_MS);
152942
- entry.timeout.unref();
152943
- res.writeHead(200, { "Content-Type": "text/plain" });
152944
- res.end(entry.token);
152945
- return;
152946
- }
152947
- log.info("askpass code used twice \u2014 revoking token");
152948
- revokeGitHubToken(entry.token);
152949
- clearTimeout(entry.timeout);
152950
- codes.delete(code);
152951
- res.writeHead(409, { "Content-Type": "text/plain" });
152952
- res.end("compromised");
152953
- });
152954
- await new Promise((resolve3, reject) => {
152955
- server.on("error", reject);
152956
- server.listen(0, "127.0.0.1", () => resolve3());
152957
- });
152958
- const rawAddr = server.address();
152959
- if (!rawAddr || typeof rawAddr === "string") {
152960
- throw new Error("git auth server failed to bind");
152961
- }
152962
- const port = rawAddr.port;
152963
- log.debug(`git auth server listening on 127.0.0.1:${port}`);
152964
- function register4(token) {
152965
- const code = randomUUID3();
152966
- const timeout = setTimeout(() => {
152967
- codes.delete(code);
152968
- log.debug(`git auth code expired: ${code.slice(0, 8)}...`);
152969
- }, CODE_TTL_MS);
152970
- timeout.unref();
152971
- codes.set(code, { token, state: "pending", timeout });
152972
- return code;
152973
- }
152974
- function writeAskpassScript(code) {
152975
- const scriptId = randomUUID3();
152976
- const scriptName = `askpass-${scriptId}.js`;
152977
- const scriptPath = join13(tmpdir3, scriptName);
152978
- const content = [
152979
- `#!/usr/bin/env node`,
152980
- `var a=process.argv[2]||"";`,
152981
- `if(/^Username/i.test(a)){process.stdout.write("x-access-token\\n")}`,
152982
- `else{var h=require("http");`,
152983
- `h.get("http://127.0.0.1:${port}/${code}",function(r){`,
152984
- `if(r.statusCode===409){process.stderr.write("askpass-compromised\\n");process.exit(1)}`,
152985
- `if(r.statusCode!==200){process.exit(1)}`,
152986
- `var d="";r.on("data",function(c){d+=c});`,
152987
- `r.on("end",function(){`,
152988
- `process.stdout.write(d+"\\n");`,
152989
- `try{require("fs").unlinkSync("${scriptPath.replace(/\\/g, "\\\\")}")}catch(e){}`,
152990
- `})}).on("error",function(){process.exit(1)})}`
152991
- ].join("\n");
152992
- writeFileSync9(scriptPath, content, { mode: 448 });
152993
- return scriptPath;
152994
- }
152995
- async function close() {
152996
- for (const entry of codes.values()) {
152997
- clearTimeout(entry.timeout);
152998
- }
152999
- codes.clear();
153000
- await new Promise((resolve3) => server.close(() => resolve3()));
153001
- log.debug("git auth server closed");
153002
- }
153003
- return {
153004
- port,
153005
- register: register4,
153006
- writeAskpassScript,
153007
- close,
153008
- [Symbol.asyncDispose]: close
153009
- };
153010
- }
153011
-
153012
153334
  // utils/instructions.ts
153013
153335
  import { execSync as execSync2 } from "node:child_process";
153014
153336
  function buildRuntimeContext(ctx) {
@@ -153201,7 +153523,7 @@ Rules:
153201
153523
  - Never push commits directly to the default branch or any protected branch (commonly: main, master, production, develop, staging). Always create a feature branch following the pattern: \`pullfrog/<issue-number>-<kebab-case-description>\` (e.g., \`pullfrog/123-fix-login-bug\`).
153202
153524
  - Never add co-author trailers (e.g., "Co-authored-by" or "Co-Authored-By") to commit messages.
153203
153525
  - Untracked files from tests or tooling (e.g. \`coverage/\`) often remain *after* your last commit and still block \`${t("push_branch")}\` \u2014 delete them, extend \`.gitignore\`, or only add files that truly belong in the repo.
153204
- - \`${t("push_branch")}\` runs the repository's optional **prepush** hook before the network push. If the error includes \`lifecycle hook 'prepush' failed\` (with an exit code and script output after it), the hook script exited non-zero (commonly tests or lint). Fix that or change the hook \u2014 do not describe it as an infrastructure "timeout" unless the tool output or logs clearly show a timeout.
153526
+ - \`${t("push_branch")}\` runs the repository's optional **prepush** hook (commonly tests or lint) \u2014 best-effort. On failure the output is returned, the hook is latched off, and every subsequent \`${t("push_branch")}\` call this run skips it. If the failure is unrelated to your changes (pre-existing breakage, env-dependent test, flaky check), just call \`${t("push_branch")}\` again. If it could be a real bug in your code, ${ctx.payload.shell === "disabled" ? `fix it from the failure output (shell is disabled, so you can't re-run the hook)` : `re-run the hook via the shell tool to iterate \u2014 \`${t("push_branch")}\` itself won't re-run it`}. Don't describe the failure as an infrastructure "timeout" unless the tool output clearly shows one.
153205
153527
  - If push or PR creation fails, \`${t("report_progress")}\` must summarize using the **actual** error from the tool. Do not substitute vague causes unless they match what failed.
153206
153528
 
153207
153529
  ### GitHub
@@ -153229,11 +153551,9 @@ For maximum efficiency, whenever you need to perform multiple independent operat
153229
153551
  - listing multiple directories
153230
153552
  - inspecting multiple MCP tools or resources
153231
153553
 
153232
- Do NOT parallelize operations that depend on prior output (e.g. create a file then read it), or ordered stateful mutations. Edits are not parallelizable \u2014 sequence those normally.${ctx.agentId === "opencode" ? `
153554
+ Do NOT parallelize operations that depend on prior output (e.g. create a file then read it), or ordered stateful mutations. Edits are not parallelizable \u2014 sequence those normally.
153233
153555
 
153234
- On OpenCode you also have a \`batch\` tool that bundles 1-25 independent calls into one wrapper call. Reach for it whenever you have >=2 independent calls. Native parallel tool_use and \`batch\` both achieve one round trip instead of N \u2014 use whichever your provider supports best.` : `
153235
-
153236
- Emit multiple \`tool_use\` blocks in the same assistant message for independent calls \u2014 the runtime executes them concurrently. Do not wait for one tool result before issuing the next independent call.`}
153556
+ Emit multiple \`tool_use\` blocks in the same assistant message for independent calls \u2014 the runtime executes them concurrently. Do not wait for one tool result before issuing the next independent call.
153237
153557
 
153238
153558
  ### Command execution
153239
153559
 
@@ -153251,7 +153571,7 @@ When embedding images (e.g. uploaded screenshots) in comments or PR bodies, alwa
153251
153571
 
153252
153572
  **\`report_progress\`**: call this exactly once at the end of every run with a brief final summary (1-3 sentences) unless the mode guidance instructs otherwise. Never call it for intermediate status updates (e.g., "Checking for changes...", "Starting review...") \u2014 the task list handles live progress automatically. Calling \`report_progress\` replaces the task list with your summary and preserves the current task list in a collapsible section. Keep the summary concise \u2014 do not repeat what the task list already shows. Focus on the outcome (what was accomplished, links to artifacts) rather than listing individual steps. If something failed, include the tool's error text even when that makes the summary longer.
153253
153573
 
153254
- Never use \`create_issue_comment\` for task progress \u2014 that creates duplicate comments and leaves the progress comment stuck in its initial state. \`create_issue_comment\` is only for standalone comments unrelated to your current task (e.g., Plan comments).
153574
+ Never use \`create_issue_comment\` for task progress \u2014 that creates duplicate comments and leaves the progress comment stuck in its initial state. \`create_issue_comment\` is only for standalone comments unrelated to your current task. Plan output (initial post AND revisions) goes through \`report_progress\` \u2014 see the Plan mode guidance for details.
153255
153575
 
153256
153576
  ### If you get stuck
153257
153577
 
@@ -153290,8 +153610,8 @@ function renderLearningsToc(headings) {
153290
153610
  }
153291
153611
  function buildLearningsSection(ctx) {
153292
153612
  if (!ctx.filePath) return "";
153293
- const intro = `Repo-level learnings accumulated by previous agent runs live at \`${ctx.filePath}\`. Use this file as durable context (test commands, conventions, gotchas, architecture notes).`;
153294
- const tocBody = ctx.headings.length === 0 ? "(no headings yet \u2014 file is empty or a flat list. read the whole file. during the post-run reflection turn, structure it with `## ` / `### ` headings so future runs can read targeted ranges.)" : `Read targeted line ranges via your native file tool \u2014 do NOT slurp the whole file. Each range starts at the section heading line, so reading the range gives you heading + body together.
153613
+ const intro = `The repo-level learnings file at \`${ctx.filePath}\` holds durable context (test commands, conventions, gotchas, architecture notes) maintained across runs.`;
153614
+ const tocBody = ctx.headings.length === 0 ? "(no headings yet \u2014 the file is empty or contains a flat list. read the whole file if it has content. during the post-run reflection turn, structure it with `## ` / `### ` headings so future runs can read targeted ranges.)" : `Read targeted line ranges via your native file tool \u2014 do NOT slurp the whole file. Each range starts at the section heading line, so reading the range gives you heading + body together. The ranges below are a run-start snapshot: any edit shifts the line numbers of every later section, so re-read the TOC range you need before relying on it.
153295
153615
 
153296
153616
  ${renderLearningsToc(ctx.headings)}`;
153297
153617
  return `************* LEARNINGS *************
@@ -153361,18 +153681,10 @@ function resolveInstructions(ctx) {
153361
153681
 
153362
153682
  // utils/learnings.ts
153363
153683
  import { mkdir, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
153364
- import { dirname as dirname4, join as join14 } from "node:path";
153365
- var LEARNINGS_FILE_NAME = "pullfrog-learnings.md";
153684
+ import { dirname as dirname4, join as join15 } from "node:path";
153685
+
153686
+ // utils/learningsTruncate.ts
153366
153687
  var MAX_LEARNINGS_LENGTH = 1e5;
153367
- function learningsFilePath(tmpdir3) {
153368
- return join14(tmpdir3, LEARNINGS_FILE_NAME);
153369
- }
153370
- async function seedLearningsFile(params) {
153371
- const path3 = learningsFilePath(params.tmpdir);
153372
- await mkdir(dirname4(path3), { recursive: true });
153373
- await writeFile2(path3, params.current ?? "", "utf8");
153374
- return path3;
153375
- }
153376
153688
  var TRUNCATION_LINE_BOUNDARY_TOLERANCE = 4096;
153377
153689
  function truncateAtLineBoundary(body, cap) {
153378
153690
  if (body.length <= cap) return body;
@@ -153382,6 +153694,18 @@ function truncateAtLineBoundary(body, cap) {
153382
153694
  if (cap - lastNewline > TRUNCATION_LINE_BOUNDARY_TOLERANCE) return head;
153383
153695
  return head.slice(0, lastNewline);
153384
153696
  }
153697
+
153698
+ // utils/learnings.ts
153699
+ var LEARNINGS_FILE_NAME = "pullfrog-learnings.md";
153700
+ function learningsFilePath(tmpdir3) {
153701
+ return join15(tmpdir3, LEARNINGS_FILE_NAME);
153702
+ }
153703
+ async function seedLearningsFile(params) {
153704
+ const path3 = learningsFilePath(params.tmpdir);
153705
+ await mkdir(dirname4(path3), { recursive: true });
153706
+ await writeFile2(path3, params.current ?? "", "utf8");
153707
+ return path3;
153708
+ }
153385
153709
  async function readLearningsFile(path3) {
153386
153710
  let raw2;
153387
153711
  try {
@@ -153391,6 +153715,45 @@ async function readLearningsFile(path3) {
153391
153715
  }
153392
153716
  return truncateAtLineBoundary(raw2.trim(), MAX_LEARNINGS_LENGTH);
153393
153717
  }
153718
+ async function persistLearnings(ctx) {
153719
+ const filePath = ctx.toolState.learningsFilePath;
153720
+ if (!filePath) return;
153721
+ if (ctx.toolState.learningsPersistAttempted) return;
153722
+ ctx.toolState.learningsPersistAttempted = true;
153723
+ const current = await readLearningsFile(filePath);
153724
+ if (current === null) {
153725
+ log.debug(`learnings tmpfile missing or unreadable at ${filePath} \u2014 skipping persist`);
153726
+ return;
153727
+ }
153728
+ const seed = ctx.toolState.learningsSeed?.trim() ?? "";
153729
+ if (current === seed) {
153730
+ log.debug("learnings tmpfile unchanged from seed \u2014 skipping persist");
153731
+ return;
153732
+ }
153733
+ try {
153734
+ const response = await apiFetch({
153735
+ path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/learnings`,
153736
+ method: "PATCH",
153737
+ headers: {
153738
+ authorization: `Bearer ${ctx.apiToken}`,
153739
+ "content-type": "application/json"
153740
+ },
153741
+ body: JSON.stringify({
153742
+ learnings: current,
153743
+ model: ctx.toolState.model
153744
+ }),
153745
+ signal: AbortSignal.timeout(1e4)
153746
+ });
153747
+ if (!response.ok) {
153748
+ const error49 = await response.text().catch(() => "(no body)");
153749
+ log.warning(`learnings persist failed (${response.status}): ${error49}`);
153750
+ return;
153751
+ }
153752
+ log.info("\xBB learnings updated");
153753
+ } catch (err) {
153754
+ log.warning(`learnings persist failed: ${err instanceof Error ? err.message : String(err)}`);
153755
+ }
153756
+ }
153394
153757
 
153395
153758
  // utils/normalizeEnv.ts
153396
153759
  var core4 = __toESM(require_core(), 1);
@@ -153450,8 +153813,63 @@ function normalizeEnv() {
153450
153813
  }
153451
153814
  }
153452
153815
 
153453
- // utils/payload.ts
153816
+ // utils/overrides.ts
153454
153817
  var core5 = __toESM(require_core(), 1);
153818
+ var DENIED_OVERRIDE_NAMES = /* @__PURE__ */ new Set([
153819
+ "GITHUB_TOKEN",
153820
+ "GH_TOKEN",
153821
+ "ACTIONS_RUNTIME_TOKEN",
153822
+ "ACTIONS_RUNTIME_URL",
153823
+ "ACTIONS_ID_TOKEN_REQUEST_URL",
153824
+ "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
153825
+ "ACTIONS_CACHE_URL",
153826
+ "PULLFROG_API_SECRET",
153827
+ "VERCEL_AUTOMATION_BYPASS_SECRET"
153828
+ ]);
153829
+ function parseOverrides(raw2) {
153830
+ const trimmed = raw2.trim();
153831
+ if (!trimmed) return {};
153832
+ let parsed2;
153833
+ try {
153834
+ parsed2 = JSON.parse(trimmed);
153835
+ } catch (err) {
153836
+ throw new Error(
153837
+ `invalid UNSAFE_OVERRIDES: not valid JSON (${err instanceof Error ? err.message : String(err)})`
153838
+ );
153839
+ }
153840
+ if (!parsed2 || typeof parsed2 !== "object" || Array.isArray(parsed2)) {
153841
+ throw new Error(`invalid UNSAFE_OVERRIDES: must be a JSON object`);
153842
+ }
153843
+ const out = {};
153844
+ for (const [key, value2] of Object.entries(parsed2)) {
153845
+ if (typeof value2 !== "string") {
153846
+ throw new Error(
153847
+ `invalid UNSAFE_OVERRIDES: key "${key}" must have a string value (got ${typeof value2})`
153848
+ );
153849
+ }
153850
+ out[key] = value2;
153851
+ }
153852
+ return out;
153853
+ }
153854
+ function applyOverrides(params) {
153855
+ const overrides = parseOverrides(params.raw);
153856
+ const applied = [];
153857
+ const denied = [];
153858
+ for (const [key, value2] of Object.entries(overrides)) {
153859
+ if (DENIED_OVERRIDE_NAMES.has(key)) {
153860
+ denied.push(key);
153861
+ continue;
153862
+ }
153863
+ if (value2.length > 0) core5.setSecret(value2);
153864
+ params.env[key] = value2;
153865
+ applied.push(key);
153866
+ }
153867
+ delete params.env.UNSAFE_OVERRIDES;
153868
+ return { applied, denied };
153869
+ }
153870
+
153871
+ // utils/payload.ts
153872
+ var core6 = __toESM(require_core(), 1);
153455
153873
  import { isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
153456
153874
 
153457
153875
  // utils/versioning.ts
@@ -153515,7 +153933,7 @@ function resolveCwd(cwd) {
153515
153933
  return workspace ? resolve2(workspace, cwd) : cwd;
153516
153934
  }
153517
153935
  function resolvePromptInput() {
153518
- const prompt = core5.getInput("prompt", { required: true });
153936
+ const prompt = core6.getInput("prompt", { required: true });
153519
153937
  let parsed2;
153520
153938
  try {
153521
153939
  parsed2 = JSON.parse(prompt);
@@ -153531,11 +153949,11 @@ function resolvePromptInput() {
153531
153949
  }
153532
153950
  function resolveNonPromptInputs() {
153533
153951
  return Inputs.omit("prompt").assert({
153534
- model: core5.getInput("model") || void 0,
153535
- timeout: core5.getInput("timeout") || void 0,
153536
- cwd: core5.getInput("cwd") || void 0,
153537
- push: core5.getInput("push") || void 0,
153538
- shell: core5.getInput("shell") || void 0
153952
+ model: core6.getInput("model") || void 0,
153953
+ timeout: core6.getInput("timeout") || void 0,
153954
+ cwd: core6.getInput("cwd") || void 0,
153955
+ push: core6.getInput("push") || void 0,
153956
+ shell: core6.getInput("shell") || void 0
153539
153957
  });
153540
153958
  }
153541
153959
  var isPullfrog = (actor) => {
@@ -153581,10 +153999,386 @@ function resolvePayload(resolvedPromptInput, repoSettings) {
153581
153999
  proxyModel: void 0
153582
154000
  };
153583
154001
  }
154002
+ function resolveOutputSchema() {
154003
+ const raw2 = core6.getInput("output_schema");
154004
+ if (!raw2) return void 0;
154005
+ let parsed2;
154006
+ try {
154007
+ parsed2 = JSON.parse(raw2);
154008
+ } catch {
154009
+ throw new Error(`invalid output_schema: not valid JSON`);
154010
+ }
154011
+ if (!parsed2 || typeof parsed2 !== "object" || Array.isArray(parsed2)) {
154012
+ throw new Error(`invalid output_schema: must be a JSON object`);
154013
+ }
154014
+ log.info("\xBB structured output schema provided \u2014 output will be required");
154015
+ return parsed2;
154016
+ }
154017
+
154018
+ // utils/proxy.ts
154019
+ var core8 = __toESM(require_core(), 1);
154020
+
154021
+ // utils/billingErrors.ts
154022
+ var BillingError = class extends Error {
154023
+ code;
154024
+ declineCode;
154025
+ needsReauthentication;
154026
+ constructor(message, opts = {}) {
154027
+ super(message);
154028
+ this.name = "BillingError";
154029
+ this.code = opts.code ?? null;
154030
+ this.declineCode = opts.declineCode ?? null;
154031
+ this.needsReauthentication = opts.needsReauthentication ?? false;
154032
+ }
154033
+ };
154034
+ var TransientError = class extends Error {
154035
+ constructor(message) {
154036
+ super(message);
154037
+ this.name = "TransientError";
154038
+ }
154039
+ };
154040
+ function billingConsoleUrl(owner, anchor) {
154041
+ return `https://pullfrog.com/console/${encodeURIComponent(owner)}#${anchor}`;
154042
+ }
154043
+ function formatBillingErrorSummary(error49, owner) {
154044
+ if (error49.code === "router_requires_card") {
154045
+ return [
154046
+ "**Add a card to start using Pullfrog Router.**",
154047
+ "",
154048
+ "Router proxies OpenRouter at raw cost \u2014 no platform markup. Add a card and we'll auto-reload your wallet so runs keep flowing.",
154049
+ "",
154050
+ `[Add a card \u2192](${billingConsoleUrl(owner, "model-access")})`
154051
+ ].join("\n");
154052
+ }
154053
+ if (error49.code === "router_balance_exhausted") {
154054
+ return [
154055
+ "**Your Pullfrog Router balance is exhausted.**",
154056
+ "",
154057
+ "You have a card on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.",
154058
+ "",
154059
+ `[Top up balance \u2192](${billingConsoleUrl(owner, "billing")}) \xB7 [Enable auto-reload \u2192](${billingConsoleUrl(owner, "model-access")})`
154060
+ ].join("\n");
154061
+ }
154062
+ if (error49.code === "router_keylimit_exhausted") {
154063
+ return [
154064
+ "**This run was cut short \u2014 your Pullfrog Router balance ran out mid-run.**",
154065
+ "",
154066
+ "OpenRouter stopped the agent because the per-run budget was exhausted. Your wallet is now negative; top up or enable auto-reload to keep runs flowing.",
154067
+ "",
154068
+ `[Top up balance \u2192](${billingConsoleUrl(owner, "billing")}) \xB7 [Enable auto-reload \u2192](${billingConsoleUrl(owner, "model-access")})`
154069
+ ].join("\n");
154070
+ }
154071
+ if (error49.code === "router_monthly_limit") {
154072
+ return [
154073
+ "**Pullfrog Router hit its monthly spend limit.**",
154074
+ "",
154075
+ "Auto-reloads are paused for the rest of this UTC month. Ask your admin to raise the cap, or wait for it to reset at 00:00 UTC on the 1st.",
154076
+ "",
154077
+ `[Adjust limit \u2192](${billingConsoleUrl(owner, "model-access")})`
154078
+ ].join("\n");
154079
+ }
154080
+ if (error49.needsReauthentication) {
154081
+ const code = error49.declineCode ?? "authentication_required";
154082
+ return [
154083
+ `**Your card issuer requires 3D Secure on every charge** (\`${code}\`).`,
154084
+ "",
154085
+ "Pullfrog can't complete a 3DS challenge from inside a workflow. Top up your Router balance once in Stripe Checkout \u2014 subsequent runs draw from the prepaid balance without re-triggering 3DS.",
154086
+ "",
154087
+ `[Top up balance \u2192](${billingConsoleUrl(owner, "billing")})`
154088
+ ].join("\n");
154089
+ }
154090
+ if (error49.declineCode) {
154091
+ return [
154092
+ `**Your card was declined** (\`${error49.declineCode}\`).`,
154093
+ "",
154094
+ "Update your payment method and Pullfrog will retry on the next run.",
154095
+ "",
154096
+ `[Update payment method \u2192](${billingConsoleUrl(owner, "billing")})`
154097
+ ].join("\n");
154098
+ }
154099
+ return [
154100
+ "**Your Pullfrog balance is empty.**",
154101
+ "",
154102
+ "Top up your balance or enable auto-reload to keep runs flowing.",
154103
+ "",
154104
+ `[Manage billing \u2192](${billingConsoleUrl(owner, "billing")})`
154105
+ ].join("\n");
154106
+ }
154107
+ function formatTransientErrorSummary(error49, owner) {
154108
+ return [
154109
+ "**Pullfrog billing is temporarily unavailable.**",
154110
+ "",
154111
+ error49.message,
154112
+ "",
154113
+ `Usually transient \u2014 the next dispatch should succeed. If it persists, check [status.pullfrog.com](https://status.pullfrog.com) or [your console](${billingConsoleUrl(owner, "billing")}).`
154114
+ ].join("\n");
154115
+ }
154116
+
154117
+ // utils/token.ts
154118
+ var core7 = __toESM(require_core(), 1);
154119
+ import assert2 from "node:assert/strict";
154120
+ var mcpTokenValue;
154121
+ function getJobToken() {
154122
+ const inputToken = core7.getInput("token");
154123
+ if (inputToken) {
154124
+ return inputToken;
154125
+ }
154126
+ const fallbackToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
154127
+ if (fallbackToken) {
154128
+ return fallbackToken;
154129
+ }
154130
+ throw new Error("token input is required");
154131
+ }
154132
+ async function resolveTokens(params) {
154133
+ assert2(!mcpTokenValue, "tokens are already resolved");
154134
+ const externalToken = process.env.GH_TOKEN;
154135
+ if (externalToken) {
154136
+ mcpTokenValue = externalToken;
154137
+ if (isGitHubActions) {
154138
+ core7.setSecret(externalToken);
154139
+ }
154140
+ log.info("\xBB using external GH_TOKEN for both git and MCP");
154141
+ return {
154142
+ gitToken: externalToken,
154143
+ mcpToken: externalToken,
154144
+ async [Symbol.asyncDispose]() {
154145
+ mcpTokenValue = void 0;
154146
+ }
154147
+ };
154148
+ }
154149
+ const gitPermissions = params.push === "disabled" ? { contents: "read" } : { contents: "write", workflows: "write" };
154150
+ const gitToken = await acquireNewToken({ permissions: gitPermissions });
154151
+ if (isGitHubActions) {
154152
+ core7.setSecret(gitToken);
154153
+ }
154154
+ log.info(
154155
+ `\xBB acquired git token (${Object.entries(gitPermissions).map((e) => e.join(":")).join(", ")})`
154156
+ );
154157
+ const mcpPermissions = {
154158
+ contents: "write",
154159
+ pull_requests: "write",
154160
+ issues: "write",
154161
+ checks: "read",
154162
+ actions: "read"
154163
+ };
154164
+ const mcpToken = await acquireNewToken({ permissions: mcpPermissions });
154165
+ if (isGitHubActions) {
154166
+ core7.setSecret(mcpToken);
154167
+ }
154168
+ log.info(
154169
+ `\xBB acquired scoped MCP token (${Object.entries(mcpPermissions).map((e) => e.join(":")).join(", ")})`
154170
+ );
154171
+ mcpTokenValue = mcpToken;
154172
+ let disposingRef;
154173
+ const dispose = async () => {
154174
+ if (disposingRef) {
154175
+ return disposingRef.promise;
154176
+ }
154177
+ disposingRef = Promise.withResolvers();
154178
+ try {
154179
+ mcpTokenValue = void 0;
154180
+ await Promise.all([
154181
+ revokeGitHubInstallationToken(gitToken),
154182
+ revokeGitHubInstallationToken(mcpToken)
154183
+ ]);
154184
+ } finally {
154185
+ removeSignalHandler();
154186
+ disposingRef.resolve();
154187
+ disposingRef = void 0;
154188
+ }
154189
+ };
154190
+ const removeSignalHandler = onExitSignal(dispose);
154191
+ return {
154192
+ gitToken,
154193
+ mcpToken,
154194
+ [Symbol.asyncDispose]: dispose
154195
+ };
154196
+ }
154197
+ function getGitHubInstallationToken() {
154198
+ assert2(mcpTokenValue, "tokens not set. call resolveTokens first.");
154199
+ return mcpTokenValue;
154200
+ }
154201
+ async function revokeGitHubInstallationToken(token) {
154202
+ const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
154203
+ try {
154204
+ await fetch(`${apiUrl}/installation/token`, {
154205
+ method: "DELETE",
154206
+ headers: {
154207
+ Accept: "application/vnd.github+json",
154208
+ Authorization: `Bearer ${token}`,
154209
+ "X-GitHub-Api-Version": "2022-11-28"
154210
+ }
154211
+ });
154212
+ log.debug("\xBB installation token revoked");
154213
+ } catch (error49) {
154214
+ log.info(
154215
+ `Failed to revoke installation token: ${error49 instanceof Error ? error49.message : String(error49)}`
154216
+ );
154217
+ }
154218
+ }
154219
+
154220
+ // utils/errorReport.ts
154221
+ async function reportErrorToComment(ctx) {
154222
+ const formattedError = ctx.title ? `${ctx.title}
154223
+
154224
+ ${ctx.error}` : ctx.error;
154225
+ const repoContext = parseRepoContext();
154226
+ const octokit = createOctokit(getGitHubInstallationToken());
154227
+ const runId = process.env.GITHUB_RUN_ID ? Number.parseInt(process.env.GITHUB_RUN_ID, 10) : void 0;
154228
+ const customParts = [];
154229
+ if (runId) {
154230
+ const apiUrl = getApiUrl();
154231
+ customParts.push(
154232
+ `[Rerun failed job \u2794](${apiUrl}/trigger/${repoContext.owner}/${repoContext.name}/${runId}?action=rerun)`
154233
+ );
154234
+ }
154235
+ const footer = buildPullfrogFooter({
154236
+ triggeredBy: true,
154237
+ workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : void 0,
154238
+ customParts,
154239
+ model: ctx.toolState.model,
154240
+ fallbackFrom: ctx.toolState.modelFallback?.from
154241
+ });
154242
+ const body = `${formattedError}${footer}`;
154243
+ const comment = ctx.toolState.progressComment;
154244
+ if (comment) {
154245
+ await updateProgressComment(
154246
+ { octokit, owner: repoContext.owner, repo: repoContext.name },
154247
+ comment,
154248
+ body
154249
+ );
154250
+ ctx.toolState.wasUpdated = true;
154251
+ return;
154252
+ }
154253
+ if (!ctx.createIfMissing) return;
154254
+ if (!ctx.toolState.issueNumber) return;
154255
+ try {
154256
+ const created = await octokit.rest.issues.createComment({
154257
+ owner: repoContext.owner,
154258
+ repo: repoContext.name,
154259
+ issue_number: ctx.toolState.issueNumber,
154260
+ body
154261
+ });
154262
+ ctx.toolState.progressComment = { id: created.data.id, type: "issue" };
154263
+ ctx.toolState.wasUpdated = true;
154264
+ } catch (error49) {
154265
+ log.warning(
154266
+ `[errorReport] fallback comment create failed: ${error49 instanceof Error ? error49.message : String(error49)}`
154267
+ );
154268
+ }
154269
+ }
154270
+
154271
+ // utils/proxy.ts
154272
+ async function mintProxyKey(ctx) {
154273
+ try {
154274
+ const headers = await buildProxyTokenHeaders(ctx);
154275
+ if (!headers) return null;
154276
+ const response = await apiFetch({
154277
+ path: "/api/proxy-token",
154278
+ method: "POST",
154279
+ headers
154280
+ });
154281
+ if (response.status === 402) {
154282
+ const body = await response.json().catch(() => null);
154283
+ throw new BillingError(body?.error ?? "insufficient balance", {
154284
+ code: body?.code ?? null,
154285
+ declineCode: body?.declineCode ?? null,
154286
+ needsReauthentication: body?.needsReauthentication ?? false
154287
+ });
154288
+ }
154289
+ if (response.status === 503) {
154290
+ const body = await response.json().catch(() => null);
154291
+ throw new TransientError(
154292
+ body?.error ?? "billing service temporarily unavailable \u2014 retry shortly"
154293
+ );
154294
+ }
154295
+ if (!response.ok) {
154296
+ log.warning(`proxy key mint failed (${response.status})`);
154297
+ return null;
154298
+ }
154299
+ const data = await response.json();
154300
+ return data.key;
154301
+ } catch (error49) {
154302
+ if (error49 instanceof BillingError) throw error49;
154303
+ if (error49 instanceof TransientError) throw error49;
154304
+ log.warning(`proxy key mint error: ${error49 instanceof Error ? error49.message : String(error49)}`);
154305
+ return null;
154306
+ } finally {
154307
+ delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
154308
+ delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
154309
+ }
154310
+ }
154311
+ async function buildProxyTokenHeaders(ctx) {
154312
+ if (ctx.oidcCredentials) {
154313
+ process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
154314
+ process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
154315
+ const oidcToken = await core8.getIDToken("pullfrog-api");
154316
+ delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
154317
+ delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
154318
+ return { Authorization: `Bearer ${oidcToken}` };
154319
+ }
154320
+ if (isLocalApiUrl()) {
154321
+ log.info(`\xBB proxy: dev bypass (x-dev-repo) for ${ctx.repo.owner}/${ctx.repo.name}`);
154322
+ return { "x-dev-repo": `${ctx.repo.owner}/${ctx.repo.name}` };
154323
+ }
154324
+ return null;
154325
+ }
154326
+ async function resolveProxyModel(ctx) {
154327
+ if (process.env.PULLFROG_MODEL?.trim()) return;
154328
+ if (!ctx.proxyModel) return;
154329
+ if (!ctx.oidcCredentials && !isLocalApiUrl()) {
154330
+ log.warning("\xBB proxy requested but no OIDC credentials available \u2014 skipping");
154331
+ return;
154332
+ }
154333
+ const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials, repo: ctx.repo });
154334
+ if (!key) return;
154335
+ process.env.OPENROUTER_API_KEY = key;
154336
+ core8.setSecret(key);
154337
+ ctx.payload.proxyModel = ctx.proxyModel;
154338
+ const label = ctx.oss ? "oss" : "router";
154339
+ log.info(`\xBB proxy: ${label} \u2192 ${ctx.proxyModel}`);
154340
+ }
154341
+ async function runProxyResolution(ctx) {
154342
+ try {
154343
+ await resolveProxyModel({
154344
+ payload: ctx.payload,
154345
+ oss: ctx.oss,
154346
+ proxyModel: ctx.proxyModel,
154347
+ oidcCredentials: ctx.oidcCredentials,
154348
+ repo: ctx.repo
154349
+ });
154350
+ } catch (error49) {
154351
+ if (error49 instanceof BillingError) {
154352
+ const summary2 = formatBillingErrorSummary(error49, ctx.repo.owner);
154353
+ await writeSummary(summary2).catch(() => {
154354
+ });
154355
+ await reportErrorToComment({
154356
+ toolState: ctx.toolState,
154357
+ error: summary2,
154358
+ createIfMissing: true
154359
+ }).catch(() => {
154360
+ });
154361
+ throw error49;
154362
+ }
154363
+ if (error49 instanceof TransientError) {
154364
+ const summary2 = formatTransientErrorSummary(error49, ctx.repo.owner);
154365
+ await writeSummary(summary2).catch(() => {
154366
+ });
154367
+ await reportErrorToComment({
154368
+ toolState: ctx.toolState,
154369
+ error: summary2,
154370
+ createIfMissing: true
154371
+ }).catch(() => {
154372
+ });
154373
+ throw error49;
154374
+ }
154375
+ throw error49;
154376
+ }
154377
+ }
153584
154378
 
153585
154379
  // utils/prSummary.ts
153586
154380
  import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
153587
- import { dirname as dirname5, join as join15 } from "node:path";
154381
+ import { dirname as dirname5, join as join16 } from "node:path";
153588
154382
  var SUMMARY_FILE_NAME = "pullfrog-summary.md";
153589
154383
  var SUMMARY_SCAFFOLD = `# PR summary
153590
154384
 
@@ -153594,7 +154388,7 @@ var SUMMARY_SCAFFOLD = `# PR summary
153594
154388
  var MIN_SNAPSHOT_LENGTH = 60;
153595
154389
  var MAX_SNAPSHOT_LENGTH = 32768;
153596
154390
  function summaryFilePath(tmpdir3) {
153597
- return join15(tmpdir3, SUMMARY_FILE_NAME);
154391
+ return join16(tmpdir3, SUMMARY_FILE_NAME);
153598
154392
  }
153599
154393
  async function seedSummaryFile(params) {
153600
154394
  const path3 = summaryFilePath(params.tmpdir);
@@ -153615,76 +154409,43 @@ async function readSummaryFile(path3) {
153615
154409
  if (trimmed.length > MAX_SNAPSHOT_LENGTH) return trimmed.slice(0, MAX_SNAPSHOT_LENGTH);
153616
154410
  return trimmed;
153617
154411
  }
153618
-
153619
- // utils/reviewCleanup.ts
153620
- var RE_REVIEW_PREAMBLE = "Incrementally re-review the new commits on this pull request. Use the IncrementalReview mode.";
153621
- async function postReviewCleanup(ctx) {
153622
- const review = ctx.toolState.review;
153623
- if (!review) return;
153624
- delete ctx.toolState.review;
153625
- await bestEffort(() => reportReviewNodeId(ctx, { nodeId: review.nodeId }), "reportReviewNodeId");
153626
- if (review.reviewedSha) {
153627
- await bestEffort(
153628
- () => dispatchFollowUpReReview(ctx, review.reviewedSha),
153629
- "follow-up re-review dispatch"
153630
- );
153631
- }
153632
- }
153633
- async function bestEffort(fn2, label) {
154412
+ async function fetchPreviousSnapshot(ctx, prNumber) {
154413
+ if (!ctx.githubInstallationToken) return null;
153634
154414
  try {
153635
- await fn2();
153636
- } catch (error49) {
153637
- log.debug(`${label} failed: ${error49}`);
154415
+ const response = await apiFetch({
154416
+ path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`,
154417
+ method: "GET",
154418
+ headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
154419
+ signal: AbortSignal.timeout(1e4)
154420
+ });
154421
+ if (!response.ok) return null;
154422
+ const data = await response.json();
154423
+ return typeof data.snapshot === "string" && data.snapshot.length > 0 ? data.snapshot : null;
154424
+ } catch {
154425
+ return null;
153638
154426
  }
153639
154427
  }
153640
- async function dispatchFollowUpReReview(ctx, reviewedSha) {
153641
- const issueNumber = ctx.payload.event.issue_number;
153642
- if (!issueNumber) return;
153643
- const pr = await ctx.octokit.rest.pulls.get({
153644
- owner: ctx.repo.owner,
153645
- repo: ctx.repo.name,
153646
- pull_number: issueNumber
153647
- });
153648
- if (pr.data.head.sha === reviewedSha) return;
153649
- if (pr.data.state !== "open") return;
153650
- if (pr.data.draft) return;
153651
- log.info(
153652
- `safety net: pr HEAD moved from ${reviewedSha.slice(0, 7)} to ${pr.data.head.sha.slice(0, 7)} and agent did not review inline \u2014 dispatching follow-up re-review`
153653
- );
153654
- const event = {
153655
- trigger: "pull_request_synchronize",
153656
- issue_number: issueNumber,
153657
- is_pr: true,
153658
- title: pr.data.title,
153659
- body: null,
153660
- branch: pr.data.head.ref,
153661
- before_sha: reviewedSha,
153662
- silent: true
153663
- };
153664
- if (ctx.payload.event.authorPermission) {
153665
- event.authorPermission = ctx.payload.event.authorPermission;
154428
+ async function persistSummary(ctx) {
154429
+ const filePath = ctx.toolState.summaryFilePath;
154430
+ if (!filePath) return;
154431
+ if (ctx.toolState.summaryPersistAttempted) return;
154432
+ ctx.toolState.summaryPersistAttempted = true;
154433
+ const snapshot2 = await readSummaryFile(filePath);
154434
+ if (!snapshot2) {
154435
+ log.debug(`pr summary tmpfile missing or invalid at ${filePath} \u2014 skipping persist`);
154436
+ return;
153666
154437
  }
153667
- const payload = {
153668
- "~pullfrog": true,
153669
- version: ctx.payload.version,
153670
- model: ctx.payload.model,
153671
- prompt: "",
153672
- eventInstructions: RE_REVIEW_PREAMBLE,
153673
- event
153674
- };
153675
- await ctx.octokit.rest.actions.createWorkflowDispatch({
153676
- owner: ctx.repo.owner,
153677
- repo: ctx.repo.name,
153678
- workflow_id: getCurrentWorkflowFilename(),
153679
- ref: pr.data.base.repo.default_branch,
153680
- inputs: { prompt: JSON.stringify(payload) }
154438
+ const seed = ctx.toolState.summarySeed?.trim();
154439
+ if (seed !== void 0 && snapshot2 === seed) {
154440
+ log.warning(
154441
+ "\xBB pr summary tmpfile unchanged from seed \u2014 skipping persist (agent did not edit it)"
154442
+ );
154443
+ return;
154444
+ }
154445
+ await patchWorkflowRunFields(ctx, { summarySnapshot: snapshot2 }).catch((err) => {
154446
+ log.debug(`pr summary persist failed: ${err instanceof Error ? err.message : String(err)}`);
153681
154447
  });
153682
154448
  }
153683
- function getCurrentWorkflowFilename() {
153684
- const ref = process.env.GITHUB_WORKFLOW_REF ?? "";
153685
- const match3 = ref.match(/\/([^/]+)@/);
153686
- return match3?.[1] ?? "pullfrog.yml";
153687
- }
153688
154449
 
153689
154450
  // utils/run.ts
153690
154451
  async function handleAgentResult(ctx) {
@@ -153720,10 +154481,10 @@ async function handleAgentResult(ctx) {
153720
154481
  };
153721
154482
  }
153722
154483
 
154484
+ // utils/runContextData.ts
154485
+ var core9 = __toESM(require_core(), 1);
154486
+
153723
154487
  // utils/runContext.ts
153724
- function isInfraCovered(params) {
153725
- return params.isOss || params.plan === "payg";
153726
- }
153727
154488
  var defaultSettings = {
153728
154489
  model: null,
153729
154490
  modes: [],
@@ -153793,13 +154554,12 @@ async function fetchRunContext(params) {
153793
154554
  }
153794
154555
 
153795
154556
  // utils/runContextData.ts
153796
- var core6 = __toESM(require_core(), 1);
153797
154557
  async function resolveRunContextData(params) {
153798
154558
  log.info(`\xBB running Pullfrog v${package_default.version}...`);
153799
154559
  const repoContext = parseRepoContext();
153800
154560
  let oidcToken;
153801
154561
  try {
153802
- oidcToken = await core6.getIDToken("pullfrog-api");
154562
+ oidcToken = await core9.getIDToken("pullfrog-api");
153803
154563
  } catch {
153804
154564
  }
153805
154565
  const [repoResponse, runContext] = await Promise.all([
@@ -153821,13 +154581,240 @@ async function resolveRunContextData(params) {
153821
154581
  };
153822
154582
  }
153823
154583
 
154584
+ // utils/runErrorRenderer.ts
154585
+ function renderRunError(input) {
154586
+ const billingError = isRouterKeylimitExhaustedError(input.errorMessage) ? new BillingError(input.errorMessage, { code: "router_keylimit_exhausted" }) : null;
154587
+ if (billingError) {
154588
+ const body = formatBillingErrorSummary(billingError, input.repo.owner);
154589
+ return { summary: body, comment: body };
154590
+ }
154591
+ const isHang = input.errorMessage.startsWith("activity timeout") || input.errorMessage.startsWith("agent still pending");
154592
+ const hangBody = isHang ? formatAgentHangBody({
154593
+ diagnostic: input.agentDiagnostic,
154594
+ isHang: true,
154595
+ errorMessage: input.errorMessage
154596
+ }) : null;
154597
+ const apiKeySource = hangBody ?? input.errorMessage;
154598
+ const apiKeyErrorSummary = isApiKeyAuthError(apiKeySource) ? formatApiKeyErrorSummary({
154599
+ owner: input.repo.owner,
154600
+ name: input.repo.name,
154601
+ raw: apiKeySource
154602
+ }) : null;
154603
+ if (apiKeyErrorSummary) {
154604
+ return { summary: apiKeyErrorSummary, comment: apiKeyErrorSummary };
154605
+ }
154606
+ if (hangBody) {
154607
+ return {
154608
+ summary: `### \u274C Pullfrog failed
154609
+
154610
+ ${hangBody}`,
154611
+ comment: hangBody
154612
+ };
154613
+ }
154614
+ return {
154615
+ summary: `### \u274C Pullfrog failed
154616
+
154617
+ \`\`\`
154618
+ ${input.errorMessage}
154619
+ \`\`\``,
154620
+ comment: input.errorMessage
154621
+ };
154622
+ }
154623
+
154624
+ // utils/runLifecycle.ts
154625
+ var core10 = __toESM(require_core(), 1);
154626
+
154627
+ // utils/reviewCleanup.ts
154628
+ var RE_REVIEW_PREAMBLE = "Incrementally re-review the new commits on this pull request. Use the IncrementalReview mode.";
154629
+ async function postReviewCleanup(ctx) {
154630
+ const review = ctx.toolState.review;
154631
+ if (!review) return;
154632
+ delete ctx.toolState.review;
154633
+ await bestEffort(() => reportReviewNodeId(ctx, { nodeId: review.nodeId }), "reportReviewNodeId");
154634
+ if (review.reviewedSha) {
154635
+ await bestEffort(
154636
+ () => dispatchFollowUpReReview(ctx, review.reviewedSha),
154637
+ "follow-up re-review dispatch"
154638
+ );
154639
+ }
154640
+ }
154641
+ async function bestEffort(fn2, label) {
154642
+ try {
154643
+ await fn2();
154644
+ } catch (error49) {
154645
+ log.debug(`${label} failed: ${error49}`);
154646
+ }
154647
+ }
154648
+ async function dispatchFollowUpReReview(ctx, reviewedSha) {
154649
+ const issueNumber = ctx.payload.event.issue_number;
154650
+ if (!issueNumber) return;
154651
+ const pr = await ctx.octokit.rest.pulls.get({
154652
+ owner: ctx.repo.owner,
154653
+ repo: ctx.repo.name,
154654
+ pull_number: issueNumber
154655
+ });
154656
+ if (pr.data.head.sha === reviewedSha) return;
154657
+ if (pr.data.state !== "open") return;
154658
+ if (pr.data.draft) return;
154659
+ log.info(
154660
+ `safety net: pr HEAD moved from ${reviewedSha.slice(0, 7)} to ${pr.data.head.sha.slice(0, 7)} and agent did not review inline \u2014 dispatching follow-up re-review`
154661
+ );
154662
+ const event = {
154663
+ trigger: "pull_request_synchronize",
154664
+ issue_number: issueNumber,
154665
+ is_pr: true,
154666
+ title: pr.data.title,
154667
+ body: null,
154668
+ branch: pr.data.head.ref,
154669
+ before_sha: reviewedSha,
154670
+ silent: true
154671
+ };
154672
+ if (ctx.payload.event.authorPermission) {
154673
+ event.authorPermission = ctx.payload.event.authorPermission;
154674
+ }
154675
+ const payload = {
154676
+ "~pullfrog": true,
154677
+ version: ctx.payload.version,
154678
+ model: ctx.payload.model,
154679
+ prompt: "",
154680
+ eventInstructions: RE_REVIEW_PREAMBLE,
154681
+ event
154682
+ };
154683
+ await ctx.octokit.rest.actions.createWorkflowDispatch({
154684
+ owner: ctx.repo.owner,
154685
+ repo: ctx.repo.name,
154686
+ workflow_id: getCurrentWorkflowFilename(),
154687
+ ref: pr.data.base.repo.default_branch,
154688
+ inputs: { prompt: JSON.stringify(payload) }
154689
+ });
154690
+ }
154691
+ function getCurrentWorkflowFilename() {
154692
+ const ref = process.env.GITHUB_WORKFLOW_REF ?? "";
154693
+ const match3 = ref.match(/\/([^/]+)@/);
154694
+ return match3?.[1] ?? "pullfrog.yml";
154695
+ }
154696
+
154697
+ // utils/runLifecycle.ts
154698
+ async function persistRunArtifacts(toolContext) {
154699
+ await postReviewCleanup(toolContext).catch((error49) => {
154700
+ log.debug(`post-review cleanup failed: ${error49}`);
154701
+ });
154702
+ await persistSummary(toolContext);
154703
+ await persistLearnings(toolContext);
154704
+ }
154705
+ async function finalizeSuccessRun(input) {
154706
+ await persistRunArtifacts(input.toolContext);
154707
+ if (!input.result.success && input.toolState.progressComment) {
154708
+ const rawError = input.result.error || "agent run failed";
154709
+ const errorBody = isApiKeyAuthError(rawError) ? formatApiKeyErrorSummary({
154710
+ owner: input.repo.owner,
154711
+ name: input.repo.name,
154712
+ raw: rawError
154713
+ }) : rawError;
154714
+ await reportErrorToComment({ toolState: input.toolState, error: errorBody }).catch((error49) => {
154715
+ log.debug(`failure error report failed: ${error49}`);
154716
+ });
154717
+ }
154718
+ if (input.result.success && input.toolState.progressComment && !input.toolState.finalSummaryWritten) {
154719
+ await deleteProgressComment(input.toolContext).catch((error49) => {
154720
+ log.debug(`stranded progress comment cleanup failed: ${error49}`);
154721
+ });
154722
+ }
154723
+ try {
154724
+ const usageSummary = formatUsageSummary(input.toolState.usageEntries);
154725
+ const body = input.toolState.lastProgressBody || input.result.output;
154726
+ const parts = [body, usageSummary].filter(Boolean);
154727
+ if (parts.length > 0) {
154728
+ await writeSummary(parts.join("\n\n"));
154729
+ }
154730
+ } catch (error49) {
154731
+ log.debug(`job summary write failed: ${error49}`);
154732
+ }
154733
+ if (input.toolState.output) {
154734
+ log.info(`::pullfrog-output::${Buffer.from(input.toolState.output).toString("base64")}`);
154735
+ core10.setOutput("result", input.toolState.output);
154736
+ }
154737
+ }
154738
+ async function writeRunErrorOutputs(input) {
154739
+ try {
154740
+ const usageSummary = formatUsageSummary(input.toolState.usageEntries);
154741
+ const parts = [input.rendered.summary, input.toolState.lastProgressBody, usageSummary].filter(
154742
+ Boolean
154743
+ );
154744
+ await writeSummary(parts.join("\n\n"));
154745
+ } catch {
154746
+ }
154747
+ try {
154748
+ await reportErrorToComment({ toolState: input.toolState, error: input.rendered.comment });
154749
+ } catch {
154750
+ }
154751
+ }
154752
+
154753
+ // utils/time.ts
154754
+ var TIMEOUT_DISABLED = "none";
154755
+ var TIME_STRING_REGEX = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/;
154756
+ function parseTimeString(input) {
154757
+ const match3 = input.match(TIME_STRING_REGEX);
154758
+ if (!match3 || !match3[1] && !match3[2] && !match3[3]) return null;
154759
+ const hours = parseInt(match3[1] || "0", 10);
154760
+ const minutes = parseInt(match3[2] || "0", 10);
154761
+ const seconds = parseInt(match3[3] || "0", 10);
154762
+ return (hours * 3600 + minutes * 60 + seconds) * 1e3;
154763
+ }
154764
+ var TIMEOUT_MAX_MS = 2147483647;
154765
+ function resolveTimeoutMs(input) {
154766
+ if (!input) return null;
154767
+ const parsed2 = parseTimeString(input);
154768
+ if (parsed2 === null || parsed2 <= 0 || parsed2 > TIMEOUT_MAX_MS) return null;
154769
+ return parsed2;
154770
+ }
154771
+
154772
+ // utils/runStartupLog.ts
154773
+ function resolveTimeoutForLog(timeout) {
154774
+ if (!timeout) return "1h (default)";
154775
+ if (timeout === TIMEOUT_DISABLED) return "none (disabled)";
154776
+ return timeout;
154777
+ }
154778
+ function resolveModelForLog(ctx) {
154779
+ const envModel = process.env.PULLFROG_MODEL?.trim();
154780
+ if (envModel) return `${envModel} (override via PULLFROG_MODEL)`;
154781
+ if (ctx.payload.proxyModel) return `${ctx.payload.proxyModel} (proxy)`;
154782
+ if (ctx.resolvedModel && ctx.payload.model && ctx.payload.model !== ctx.resolvedModel) {
154783
+ return `${ctx.resolvedModel} (resolved from ${ctx.payload.model})`;
154784
+ }
154785
+ if (ctx.resolvedModel) return ctx.resolvedModel;
154786
+ if (ctx.payload.model) return `${ctx.payload.model} (unresolved)`;
154787
+ return "auto";
154788
+ }
154789
+ function resolveAgentForLog(ctx) {
154790
+ const envAgent = process.env.PULLFROG_AGENT?.trim();
154791
+ if (envAgent && envAgent === ctx.agentName) {
154792
+ return `${ctx.agentName} (override via PULLFROG_AGENT)`;
154793
+ }
154794
+ if (ctx.agentName === "claude" && ctx.resolvedModel) {
154795
+ return `${ctx.agentName} (auto-selected for ${ctx.resolvedModel})`;
154796
+ }
154797
+ return ctx.agentName;
154798
+ }
154799
+ function logRunStartup(ctx) {
154800
+ log.info(
154801
+ `\xBB model: ${resolveModelForLog({ payload: ctx.payload, resolvedModel: ctx.resolvedModel })}`
154802
+ );
154803
+ log.info(
154804
+ `\xBB agent: ${resolveAgentForLog({ agentName: ctx.agentName, resolvedModel: ctx.resolvedModel })}`
154805
+ );
154806
+ log.info(`\xBB push: ${ctx.payload.push}`);
154807
+ log.info(`\xBB shell: ${ctx.payload.shell}`);
154808
+ log.info(`\xBB timeout: ${resolveTimeoutForLog(ctx.payload.timeout)}`);
154809
+ }
154810
+
153824
154811
  // utils/setup.ts
153825
154812
  import { execFileSync as execFileSync5, execSync as execSync3 } from "node:child_process";
153826
154813
  import { mkdtempSync } from "node:fs";
153827
154814
  import { tmpdir as tmpdir2 } from "node:os";
153828
- import { join as join16 } from "node:path";
154815
+ import { join as join17 } from "node:path";
153829
154816
  function createTempDirectory() {
153830
- const sharedTempDir = mkdtempSync(join16(tmpdir2(), "pullfrog-"));
154817
+ const sharedTempDir = mkdtempSync(join17(tmpdir2(), "pullfrog-"));
153831
154818
  process.env.PULLFROG_TEMP_DIR = sharedTempDir;
153832
154819
  log.info(`\xBB created temp dir at ${sharedTempDir}`);
153833
154820
  return sharedTempDir;
@@ -153931,25 +154918,6 @@ async function setupGit(params) {
153931
154918
  log.info("\xBB git authentication configured");
153932
154919
  }
153933
154920
 
153934
- // utils/time.ts
153935
- var TIMEOUT_DISABLED = "none";
153936
- var TIME_STRING_REGEX = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/;
153937
- function parseTimeString(input) {
153938
- const match3 = input.match(TIME_STRING_REGEX);
153939
- if (!match3 || !match3[1] && !match3[2] && !match3[3]) return null;
153940
- const hours = parseInt(match3[1] || "0", 10);
153941
- const minutes = parseInt(match3[2] || "0", 10);
153942
- const seconds = parseInt(match3[3] || "0", 10);
153943
- return (hours * 3600 + minutes * 60 + seconds) * 1e3;
153944
- }
153945
- var TIMEOUT_MAX_MS = 2147483647;
153946
- function resolveTimeoutMs(input) {
153947
- if (!input) return null;
153948
- const parsed2 = parseTimeString(input);
153949
- if (parsed2 === null || parsed2 <= 0 || parsed2 > TIMEOUT_MAX_MS) return null;
153950
- return parsed2;
153951
- }
153952
-
153953
154921
  // utils/todoTracking.ts
153954
154922
  function isValidTodoStatus(value2) {
153955
154923
  return value2 === "pending" || value2 === "in_progress" || value2 === "completed" || value2 === "cancelled";
@@ -154086,305 +155054,42 @@ async function resolveRun(params) {
154086
155054
  let jobId;
154087
155055
  const jobName = process.env.GITHUB_JOB;
154088
155056
  if (jobName && runId) {
154089
- const jobs = await params.octokit.rest.actions.listJobsForWorkflowRun({
154090
- owner,
154091
- repo,
154092
- run_id: runId
154093
- });
154094
- const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
154095
- if (matchingJob) {
154096
- jobId = String(matchingJob.id);
154097
- log.debug(`\xBB found job ID: ${jobId}`);
155057
+ try {
155058
+ const jobs = await params.octokit.rest.actions.listJobsForWorkflowRun({
155059
+ owner,
155060
+ repo,
155061
+ run_id: runId
155062
+ });
155063
+ const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
155064
+ if (matchingJob) {
155065
+ jobId = String(matchingJob.id);
155066
+ log.debug(`\xBB found job ID: ${jobId}`);
155067
+ }
155068
+ } catch (err) {
155069
+ const msg = err instanceof Error ? err.message : String(err);
155070
+ log.debug(`\xBB listJobsForWorkflowRun failed (jobId stays undefined): ${msg}`);
154098
155071
  }
154099
155072
  }
154100
155073
  return { runId, jobId };
154101
155074
  }
154102
155075
 
154103
155076
  // main.ts
154104
- function resolveOutputSchema() {
154105
- const raw2 = core7.getInput("output_schema");
154106
- if (!raw2) return void 0;
154107
- let parsed2;
154108
- try {
154109
- parsed2 = JSON.parse(raw2);
154110
- } catch {
154111
- throw new Error(`invalid output_schema: not valid JSON`);
154112
- }
154113
- if (!parsed2 || typeof parsed2 !== "object" || Array.isArray(parsed2)) {
154114
- throw new Error(`invalid output_schema: must be a JSON object`);
154115
- }
154116
- log.info("\xBB structured output schema provided \u2014 output will be required");
154117
- return parsed2;
154118
- }
154119
- function resolveTimeoutForLog(timeout) {
154120
- if (!timeout) return "1h (default)";
154121
- if (timeout === TIMEOUT_DISABLED) return "none (disabled)";
154122
- return timeout;
154123
- }
154124
- function resolveModelForLog(ctx) {
154125
- const envModel = process.env.PULLFROG_MODEL?.trim();
154126
- if (envModel) return `${envModel} (override via PULLFROG_MODEL)`;
154127
- if (ctx.payload.proxyModel) return `${ctx.payload.proxyModel} (proxy)`;
154128
- if (ctx.resolvedModel && ctx.payload.model && ctx.payload.model !== ctx.resolvedModel) {
154129
- return `${ctx.resolvedModel} (resolved from ${ctx.payload.model})`;
154130
- }
154131
- if (ctx.resolvedModel) return ctx.resolvedModel;
154132
- if (ctx.payload.model) return `${ctx.payload.model} (unresolved)`;
154133
- return "auto";
154134
- }
154135
- function resolveAgentForLog(ctx) {
154136
- const envAgent = process.env.PULLFROG_AGENT?.trim();
154137
- if (envAgent && envAgent === ctx.agentName) {
154138
- return `${ctx.agentName} (override via PULLFROG_AGENT)`;
154139
- }
154140
- if (ctx.agentName === "claude" && ctx.resolvedModel) {
154141
- return `${ctx.agentName} (auto-selected for ${ctx.resolvedModel})`;
154142
- }
154143
- return ctx.agentName;
154144
- }
154145
- var BillingError = class extends Error {
154146
- code;
154147
- declineCode;
154148
- needsReauthentication;
154149
- constructor(message, opts = {}) {
154150
- super(message);
154151
- this.name = "BillingError";
154152
- this.code = opts.code ?? null;
154153
- this.declineCode = opts.declineCode ?? null;
154154
- this.needsReauthentication = opts.needsReauthentication ?? false;
154155
- }
154156
- };
154157
- var TransientError = class extends Error {
154158
- constructor(message) {
154159
- super(message);
154160
- this.name = "TransientError";
154161
- }
154162
- };
154163
- function billingConsoleUrl(owner, anchor) {
154164
- return `https://pullfrog.com/console/${encodeURIComponent(owner)}#${anchor}`;
154165
- }
154166
- function formatBillingErrorSummary(error49, owner) {
154167
- if (error49.code === "router_requires_card") {
154168
- return [
154169
- "**Add a card to start using Pullfrog Router.**",
154170
- "",
154171
- "Router proxies OpenRouter at raw cost \u2014 no platform markup. Add a card and we'll auto-reload your wallet so runs keep flowing.",
154172
- "",
154173
- `[Add a card \u2192](${billingConsoleUrl(owner, "model-access")})`
154174
- ].join("\n");
154175
- }
154176
- if (error49.code === "router_balance_exhausted") {
154177
- return [
154178
- "**Your Pullfrog Router balance is exhausted.**",
154179
- "",
154180
- "You have a card on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.",
154181
- "",
154182
- `[Top up balance \u2192](${billingConsoleUrl(owner, "billing")}) \xB7 [Enable auto-reload \u2192](${billingConsoleUrl(owner, "model-access")})`
154183
- ].join("\n");
154184
- }
154185
- if (error49.code === "router_keylimit_exhausted") {
154186
- return [
154187
- "**This run was cut short \u2014 your Pullfrog Router balance ran out mid-run.**",
154188
- "",
154189
- "OpenRouter stopped the agent because the per-run budget was exhausted. Your wallet is now negative; top up or enable auto-reload to keep runs flowing.",
154190
- "",
154191
- `[Top up balance \u2192](${billingConsoleUrl(owner, "billing")}) \xB7 [Enable auto-reload \u2192](${billingConsoleUrl(owner, "model-access")})`
154192
- ].join("\n");
154193
- }
154194
- if (error49.needsReauthentication) {
154195
- const code = error49.declineCode ?? "authentication_required";
154196
- return [
154197
- `**Your card issuer requires 3D Secure on every charge** (\`${code}\`).`,
154198
- "",
154199
- "Pullfrog can't complete a 3DS challenge from inside a workflow. Top up your Router balance once in Stripe Checkout \u2014 subsequent runs draw from the prepaid balance without re-triggering 3DS.",
154200
- "",
154201
- `[Top up balance \u2192](${billingConsoleUrl(owner, "billing")})`
154202
- ].join("\n");
154203
- }
154204
- if (error49.declineCode) {
154205
- return [
154206
- `**Your card was declined** (\`${error49.declineCode}\`).`,
154207
- "",
154208
- "Update your payment method and Pullfrog will retry on the next run.",
154209
- "",
154210
- `[Update payment method \u2192](${billingConsoleUrl(owner, "billing")})`
154211
- ].join("\n");
154212
- }
154213
- return [
154214
- "**Your Pullfrog balance is empty.**",
154215
- "",
154216
- "Top up your balance or enable auto-reload to keep runs flowing.",
154217
- "",
154218
- `[Manage billing \u2192](${billingConsoleUrl(owner, "billing")})`
154219
- ].join("\n");
154220
- }
154221
- function formatTransientErrorSummary(error49, owner) {
154222
- return [
154223
- "**Pullfrog billing is temporarily unavailable.**",
154224
- "",
154225
- error49.message,
154226
- "",
154227
- `Usually transient \u2014 the next dispatch should succeed. If it persists, check [status.pullfrog.com](https://status.pullfrog.com) or [your console](${billingConsoleUrl(owner, "billing")}).`
154228
- ].join("\n");
154229
- }
154230
- async function mintProxyKey(ctx) {
154231
- try {
154232
- const headers = await buildProxyTokenHeaders(ctx);
154233
- if (!headers) return null;
154234
- const response = await apiFetch({
154235
- path: "/api/proxy-token",
154236
- method: "POST",
154237
- headers
154238
- });
154239
- if (response.status === 402) {
154240
- const body = await response.json().catch(() => null);
154241
- throw new BillingError(body?.error ?? "insufficient balance", {
154242
- code: body?.code ?? null,
154243
- declineCode: body?.declineCode ?? null,
154244
- needsReauthentication: body?.needsReauthentication ?? false
154245
- });
154246
- }
154247
- if (response.status === 503) {
154248
- const body = await response.json().catch(() => null);
154249
- throw new TransientError(
154250
- body?.error ?? "billing service temporarily unavailable \u2014 retry shortly"
154251
- );
154252
- }
154253
- if (!response.ok) {
154254
- log.warning(`proxy key mint failed (${response.status})`);
154255
- return null;
154256
- }
154257
- const data = await response.json();
154258
- return data.key;
154259
- } catch (error49) {
154260
- if (error49 instanceof BillingError) throw error49;
154261
- if (error49 instanceof TransientError) throw error49;
154262
- log.warning(`proxy key mint error: ${error49 instanceof Error ? error49.message : String(error49)}`);
154263
- return null;
154264
- } finally {
154265
- delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
154266
- delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
154267
- }
154268
- }
154269
- async function buildProxyTokenHeaders(ctx) {
154270
- if (ctx.oidcCredentials) {
154271
- process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
154272
- process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
154273
- const oidcToken = await core7.getIDToken("pullfrog-api");
154274
- delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
154275
- delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
154276
- return { Authorization: `Bearer ${oidcToken}` };
154277
- }
154278
- if (isLocalApiUrl()) {
154279
- log.info(`\xBB proxy: dev bypass (x-dev-repo) for ${ctx.repo.owner}/${ctx.repo.name}`);
154280
- return { "x-dev-repo": `${ctx.repo.owner}/${ctx.repo.name}` };
154281
- }
154282
- return null;
154283
- }
154284
- async function resolveProxyModel(ctx) {
154285
- if (process.env.PULLFROG_MODEL?.trim()) return;
154286
- const needsProxy = isInfraCovered({ isOss: ctx.oss, plan: ctx.plan }) && ctx.proxyModel;
154287
- if (!needsProxy) return;
154288
- if (!ctx.oidcCredentials && !isLocalApiUrl()) {
154289
- log.warning("\xBB proxy requested but no OIDC credentials available \u2014 skipping");
154290
- return;
154291
- }
154292
- const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials, repo: ctx.repo });
154293
- if (!key) return;
154294
- process.env.OPENROUTER_API_KEY = key;
154295
- core7.setSecret(key);
154296
- ctx.payload.proxyModel = ctx.proxyModel;
154297
- const label = ctx.oss ? "oss" : "router";
154298
- log.info(`\xBB proxy: ${label} \u2192 ${ctx.proxyModel}`);
154299
- }
154300
- async function fetchPreviousSnapshot(ctx, prNumber) {
154301
- if (!ctx.githubInstallationToken) return null;
154302
- try {
154303
- const response = await apiFetch({
154304
- path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`,
154305
- method: "GET",
154306
- headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
154307
- signal: AbortSignal.timeout(1e4)
154308
- });
154309
- if (!response.ok) return null;
154310
- const data = await response.json();
154311
- return typeof data.snapshot === "string" && data.snapshot.length > 0 ? data.snapshot : null;
154312
- } catch {
154313
- return null;
154314
- }
154315
- }
154316
- async function persistLearnings(ctx) {
154317
- const filePath = ctx.toolState.learningsFilePath;
154318
- if (!filePath) return;
154319
- if (ctx.toolState.learningsPersistAttempted) return;
154320
- ctx.toolState.learningsPersistAttempted = true;
154321
- const current = await readLearningsFile(filePath);
154322
- if (current === null) {
154323
- log.debug(`learnings tmpfile missing or unreadable at ${filePath} \u2014 skipping persist`);
154324
- return;
154325
- }
154326
- const seed = ctx.toolState.learningsSeed?.trim() ?? "";
154327
- if (current === seed) {
154328
- log.debug("learnings tmpfile unchanged from seed \u2014 skipping persist");
154329
- return;
154330
- }
154331
- try {
154332
- const response = await apiFetch({
154333
- path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/learnings`,
154334
- method: "PATCH",
154335
- headers: {
154336
- authorization: `Bearer ${ctx.apiToken}`,
154337
- "content-type": "application/json"
154338
- },
154339
- body: JSON.stringify({
154340
- learnings: current,
154341
- model: ctx.toolState.model
154342
- }),
154343
- signal: AbortSignal.timeout(1e4)
154344
- });
154345
- if (!response.ok) {
154346
- const error49 = await response.text().catch(() => "(no body)");
154347
- log.warning(`learnings persist failed (${response.status}): ${error49}`);
154348
- return;
154349
- }
154350
- log.info("\xBB learnings updated");
154351
- } catch (err) {
154352
- log.warning(`learnings persist failed: ${err instanceof Error ? err.message : String(err)}`);
154353
- }
154354
- }
154355
- async function persistSummary(ctx) {
154356
- const filePath = ctx.toolState.summaryFilePath;
154357
- if (!filePath) return;
154358
- if (ctx.toolState.summaryPersistAttempted) return;
154359
- ctx.toolState.summaryPersistAttempted = true;
154360
- const snapshot2 = await readSummaryFile(filePath);
154361
- if (!snapshot2) {
154362
- log.debug(`pr summary tmpfile missing or invalid at ${filePath} \u2014 skipping persist`);
154363
- return;
154364
- }
154365
- const seed = ctx.toolState.summarySeed?.trim();
154366
- if (seed !== void 0 && snapshot2 === seed) {
154367
- log.warning(
154368
- "\xBB pr summary tmpfile unchanged from seed \u2014 skipping persist (agent did not edit it)"
154369
- );
154370
- return;
154371
- }
154372
- await patchWorkflowRunFields(ctx, { summarySnapshot: snapshot2 }).catch((err) => {
154373
- log.debug(`pr summary persist failed: ${err instanceof Error ? err.message : String(err)}`);
154374
- });
154375
- }
154376
- async function writeJobSummary(toolState, finalOutput) {
154377
- const usageSummary = formatUsageSummary(toolState.usageEntries);
154378
- const body = toolState.lastProgressBody || finalOutput;
154379
- const summaryParts = [body, usageSummary].filter(Boolean);
154380
- if (summaryParts.length > 0) {
154381
- await writeSummary(summaryParts.join("\n\n"));
154382
- }
154383
- }
154384
155077
  async function main() {
154385
155078
  var _stack2 = [];
154386
155079
  try {
154387
155080
  normalizeEnv();
155081
+ const overridesRaw = process.env.UNSAFE_OVERRIDES ?? "";
155082
+ if (overridesRaw.trim()) {
155083
+ const result = applyOverrides({ raw: overridesRaw, env: process.env });
155084
+ if (result.applied.length > 0) {
155085
+ log.info(`\xBB applied ${result.applied.length} env override(s): ${result.applied.join(", ")}`);
155086
+ }
155087
+ if (result.denied.length > 0) {
155088
+ log.warning(
155089
+ `\xBB refused to override ${result.denied.length} protected env var(s): ${result.denied.join(", ")}`
155090
+ );
155091
+ }
155092
+ }
154388
155093
  const usageSummaryPath = process.env.PULLFROG_USAGE_SUMMARY_PATH;
154389
155094
  if (usageSummaryPath) {
154390
155095
  onExitSignal(() => writeGitHubUsageSummaryToFile(usageSummaryPath));
@@ -154428,34 +155133,14 @@ async function main() {
154428
155133
  delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
154429
155134
  delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
154430
155135
  }
154431
- try {
154432
- await resolveProxyModel({
154433
- payload,
154434
- oss: runContext.oss,
154435
- plan: runContext.plan,
154436
- proxyModel: runContext.proxyModel,
154437
- oidcCredentials,
154438
- repo: runContext.repo
154439
- });
154440
- } catch (error49) {
154441
- if (error49 instanceof BillingError) {
154442
- const summary2 = formatBillingErrorSummary(error49, runContext.repo.owner);
154443
- await writeSummary(summary2).catch(() => {
154444
- });
154445
- await reportErrorToComment({ toolState, error: summary2 }).catch(() => {
154446
- });
154447
- throw error49;
154448
- }
154449
- if (error49 instanceof TransientError) {
154450
- const summary2 = formatTransientErrorSummary(error49, runContext.repo.owner);
154451
- await writeSummary(summary2).catch(() => {
154452
- });
154453
- await reportErrorToComment({ toolState, error: summary2 }).catch(() => {
154454
- });
154455
- throw error49;
154456
- }
154457
- throw error49;
154458
- }
155136
+ await runProxyResolution({
155137
+ payload,
155138
+ oss: runContext.oss,
155139
+ proxyModel: runContext.proxyModel,
155140
+ oidcCredentials,
155141
+ repo: runContext.repo,
155142
+ toolState
155143
+ });
154459
155144
  const octokit = createOctokit(tokenRef.mcpToken);
154460
155145
  const runInfo = await resolveRun({ octokit });
154461
155146
  let toolContext;
@@ -154482,12 +155167,24 @@ async function main() {
154482
155167
  const tmpdir3 = createTempDirectory();
154483
155168
  const gitAuthServer = __using(_stack, await startGitAuthServer(tmpdir3), true);
154484
155169
  setGitAuthServer(gitAuthServer);
154485
- const resolvedModel = payload.proxyModel ? void 0 : resolveModel({ slug: payload.model });
155170
+ const initialResolvedModel = payload.proxyModel ? void 0 : resolveModel({ slug: payload.model });
155171
+ const fallback = selectFallbackModelIfNeeded({
155172
+ resolvedModel: initialResolvedModel,
155173
+ proxyModel: payload.proxyModel
155174
+ });
155175
+ const effectiveSlug = fallback.fallback ? fallback.to : payload.model;
155176
+ const resolvedModel = fallback.fallback ? fallback.to : initialResolvedModel;
155177
+ if (fallback.fallback) {
155178
+ log.warning(
155179
+ `\xBB fell back from ${fallback.from} to ${fallback.to} \u2014 no BYOK key present in runner env. add a provider key in repo secrets to use ${fallback.from} instead.`
155180
+ );
155181
+ toolState.modelFallback = { from: fallback.from };
155182
+ }
154486
155183
  const agent2 = resolveAgent({ model: resolvedModel });
154487
- toolState.model = payload.proxyModel ?? resolvedModel ?? payload.model;
155184
+ toolState.model = payload.proxyModel ?? resolvedModel ?? effectiveSlug;
154488
155185
  validateAgentApiKey({
154489
155186
  agent: agent2,
154490
- model: payload.proxyModel ?? resolvedModel ?? payload.model,
155187
+ model: payload.proxyModel ?? resolvedModel ?? effectiveSlug,
154491
155188
  owner: runContext.repo.owner,
154492
155189
  name: runContext.repo.name
154493
155190
  });
@@ -154570,14 +155267,7 @@ async function main() {
154570
155267
  onExitSignal(() => persistSummary(ctxForExit));
154571
155268
  }
154572
155269
  startInstallation(toolContext);
154573
- const modelForLog = resolveModelForLog({ payload, resolvedModel });
154574
- const agentForLog = resolveAgentForLog({ agentName: agent2.name, resolvedModel });
154575
- const timeoutForLog = resolveTimeoutForLog(payload.timeout);
154576
- log.info(`\xBB model: ${modelForLog}`);
154577
- log.info(`\xBB agent: ${agentForLog}`);
154578
- log.info(`\xBB push: ${payload.push}`);
154579
- log.info(`\xBB shell: ${payload.shell}`);
154580
- log.info(`\xBB timeout: ${timeoutForLog}`);
155270
+ logRunStartup({ payload, resolvedModel, agentName: agent2.name });
154581
155271
  const instructions = resolveInstructions({
154582
155272
  payload,
154583
155273
  repo: runContext.repo,
@@ -154601,7 +155291,7 @@ ${instructions.user}` : null,
154601
155291
  log.info(instructions.full);
154602
155292
  });
154603
155293
  if (agentId === "opencode") {
154604
- const pluginDir = join17(process.cwd(), ".opencode", "plugin");
155294
+ const pluginDir = join18(process.cwd(), ".opencode", "plugin");
154605
155295
  const hasPlugins = existsSync7(pluginDir) && readdirSync(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
154606
155296
  if (hasPlugins && toolState.dependencyInstallation?.promise) {
154607
155297
  log.info(
@@ -154661,6 +155351,7 @@ ${instructions.user}` : null,
154661
155351
  todoTracker,
154662
155352
  stopScript: runContext.repoSettings.stopScript,
154663
155353
  toolState,
155354
+ apiToken: runContext.apiToken,
154664
155355
  onActivityTimeout: onInnerActivityTimeout,
154665
155356
  onToolUse: (event) => {
154666
155357
  const wasTracked = recordDiffReadFromToolUse({
@@ -154710,42 +155401,7 @@ ${instructions.user}` : null,
154710
155401
  "output_schema was provided but agent did not call set_output \u2014 structured output is required"
154711
155402
  );
154712
155403
  }
154713
- if (toolContext) {
154714
- await postReviewCleanup(toolContext).catch((error49) => {
154715
- log.debug(`post-review cleanup failed: ${error49}`);
154716
- });
154717
- }
154718
- if (toolContext) {
154719
- await persistSummary(toolContext);
154720
- }
154721
- if (toolContext) {
154722
- await persistLearnings(toolContext);
154723
- }
154724
- if (!result.success && toolContext && toolState.progressComment) {
154725
- const rawError = result.error || "agent run failed";
154726
- const errorBody = isApiKeyAuthError(rawError) ? formatApiKeyErrorSummary({
154727
- owner: runContext.repo.owner,
154728
- name: runContext.repo.name,
154729
- raw: rawError
154730
- }) : rawError;
154731
- await reportErrorToComment({ toolState, error: errorBody }).catch((error49) => {
154732
- log.debug(`failure error report failed: ${error49}`);
154733
- });
154734
- }
154735
- if (toolContext && result.success && toolState.progressComment && !toolState.finalSummaryWritten) {
154736
- await deleteProgressComment(toolContext).catch((error49) => {
154737
- log.debug(`stranded progress comment cleanup failed: ${error49}`);
154738
- });
154739
- }
154740
- try {
154741
- await writeJobSummary(toolState, result.output);
154742
- } catch (error49) {
154743
- log.debug(`job summary write failed: ${error49}`);
154744
- }
154745
- if (toolState.output) {
154746
- log.info(`::pullfrog-output::${Buffer.from(toolState.output).toString("base64")}`);
154747
- core7.setOutput("result", toolState.output);
154748
- }
155404
+ await finalizeSuccessRun({ toolContext, toolState, result, repo: runContext.repo });
154749
155405
  return await handleAgentResult({
154750
155406
  result,
154751
155407
  toolState,
@@ -154763,38 +155419,14 @@ ${instructions.user}` : null,
154763
155419
  todoTracker?.cancel();
154764
155420
  killTrackedChildren();
154765
155421
  log.error(errorMessage);
154766
- const billingError = isRouterKeylimitExhaustedError(errorMessage) ? new BillingError(errorMessage, { code: "router_keylimit_exhausted" }) : null;
154767
- const apiKeyErrorSummary = !billingError && isApiKeyAuthError(errorMessage) ? formatApiKeyErrorSummary({
154768
- owner: runContext.repo.owner,
154769
- name: runContext.repo.name,
154770
- raw: errorMessage
154771
- }) : null;
154772
- try {
154773
- const errorSummary = billingError ? formatBillingErrorSummary(billingError, runContext.repo.owner) : apiKeyErrorSummary ?? `### \u274C Pullfrog failed
154774
-
154775
- \`\`\`
154776
- ${errorMessage}
154777
- \`\`\``;
154778
- const usageSummary = formatUsageSummary(toolState.usageEntries);
154779
- const parts = [errorSummary, toolState.lastProgressBody, usageSummary].filter(Boolean);
154780
- await writeSummary(parts.join("\n\n"));
154781
- } catch {
154782
- }
154783
- try {
154784
- const commentBody = billingError ? formatBillingErrorSummary(billingError, runContext.repo.owner) : apiKeyErrorSummary ?? errorMessage;
154785
- await reportErrorToComment({ toolState, error: commentBody });
154786
- } catch {
154787
- }
154788
- if (toolContext) {
154789
- await postReviewCleanup(toolContext).catch((error50) => {
154790
- log.debug(`post-review cleanup failed: ${error50}`);
154791
- });
154792
- }
154793
- if (toolContext) {
154794
- await persistSummary(toolContext);
154795
- }
155422
+ const rendered = renderRunError({
155423
+ errorMessage,
155424
+ repo: runContext.repo,
155425
+ agentDiagnostic: toolState.agentDiagnostic
155426
+ });
155427
+ await writeRunErrorOutputs({ rendered, toolState });
154796
155428
  if (toolContext) {
154797
- await persistLearnings(toolContext);
155429
+ await persistRunArtifacts(toolContext);
154798
155430
  }
154799
155431
  return {
154800
155432
  success: false,