pullfrog 0.1.8 → 0.1.10

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.8",
142472
+ version: "0.1.10",
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",
@@ -142919,8 +142975,9 @@ function $(cmd, args2, options) {
142919
142975
  options.onError(errorResult);
142920
142976
  return stdout.trim();
142921
142977
  }
142978
+ const detail = [stderr, stdout].map((s) => s.trim()).filter(Boolean).join("\n");
142922
142979
  throw new Error(
142923
- `Command failed with exit code ${errorResult.status}: ${stderr || "Unknown error"}`
142980
+ `Command failed with exit code ${errorResult.status}: ${detail || "Unknown error"}`
142924
142981
  );
142925
142982
  }
142926
142983
  return stdout.trim();
@@ -143060,6 +143117,7 @@ async function executeLifecycleHook(params) {
143060
143117
  if (result.exitCode !== 0) {
143061
143118
  const output = (result.stderr || result.stdout).trim();
143062
143119
  return {
143120
+ failure: { kind: "exit", output, exitCode: result.exitCode },
143063
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.`
143064
143122
  };
143065
143123
  }
@@ -143070,11 +143128,13 @@ async function executeLifecycleHook(params) {
143070
143128
  if (isTimeout) {
143071
143129
  const minutes = Math.round(LIFECYCLE_HOOK_TIMEOUT_MS / 6e4);
143072
143130
  return {
143131
+ failure: { kind: "timeout" },
143073
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).`
143074
143133
  };
143075
143134
  }
143076
143135
  const msg = err instanceof Error ? err.message : String(err);
143077
143136
  return {
143137
+ failure: { kind: "spawn", spawnError: msg },
143078
143138
  warning: `lifecycle hook '${params.event}' failed to spawn: ${msg}. this is likely a transient failure \u2014 retry the operation.`
143079
143139
  };
143080
143140
  }
@@ -143293,7 +143353,7 @@ function PushBranchTool(ctx) {
143293
143353
  const pushPermission = ctx.payload.push;
143294
143354
  return tool({
143295
143355
  name: "push_branch",
143296
- 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.",
143297
143357
  parameters: PushBranch,
143298
143358
  execute: execute(async ({ branchName, force }) => {
143299
143359
  if (pushPermission === "disabled") {
@@ -143307,10 +143367,21 @@ function PushBranchTool(ctx) {
143307
143367
  `push blocked: working tree is not clean (tracked changes and/or untracked files). commit, discard, or remove stray artifacts before pushing.
143308
143368
 
143309
143369
  git status:
143310
- ${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." : "")
143311
143371
  );
143312
143372
  }
143313
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
+ }
143314
143385
  if (pushPermission === "restricted" && pushDest.remoteBranch === defaultBranch) {
143315
143386
  throw new Error(
143316
143387
  `Push blocked: cannot push directly to default branch '${pushDest.remoteBranch}'. Create a feature branch and open a PR instead.`
@@ -143318,21 +143389,27 @@ ${status}`
143318
143389
  }
143319
143390
  const refspec = branch === pushDest.remoteBranch ? branch : `${branch}:${pushDest.remoteBranch}`;
143320
143391
  const pushArgs = force ? ["--force", "-u", pushDest.remoteName, refspec] : ["-u", pushDest.remoteName, refspec];
143321
- const prepushHook = await executeLifecycleHook({
143322
- event: "prepush",
143323
- script: ctx.prepushScript
143324
- });
143325
- if (prepushHook.warning) {
143326
- throw new Error(prepushHook.warning);
143327
- }
143328
- const postHookStatus = $("git", ["status", "--porcelain"], { log: false });
143329
- if (postHookStatus) {
143330
- throw new Error(
143331
- `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.
143332
143408
 
143333
143409
  git status:
143334
143410
  ${postHookStatus}`
143335
- );
143411
+ );
143412
+ }
143336
143413
  }
143337
143414
  log.debug(`pushing ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`);
143338
143415
  if (force) {
@@ -143385,17 +143462,30 @@ ${integrateStep}
143385
143462
  log.info(
143386
143463
  `\xBB pushed branch ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch} (sha ${pushedSha})`
143387
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;
143388
143467
  return {
143389
143468
  success: true,
143390
143469
  branch,
143391
143470
  remoteBranch: pushDest.remoteBranch,
143392
143471
  remote: pushDest.remoteName,
143393
143472
  force,
143394
- message: `successfully pushed ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`
143473
+ prepushSkipped,
143474
+ message
143395
143475
  };
143396
143476
  })
143397
143477
  });
143398
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
+ }
143399
143489
  var AUTH_REQUIRED_REDIRECT = {
143400
143490
  push: "use the push_branch tool instead \u2014 it handles authentication and permission checks.",
143401
143491
  fetch: "use the git_fetch tool instead \u2014 it handles authentication.",
@@ -143457,6 +143547,23 @@ function GitTool(ctx) {
143457
143547
  }
143458
143548
  }
143459
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
+ }
143460
143567
  const output = $("git", [command, ...args2], { log: false });
143461
143568
  const lineCount = output.split("\n").length;
143462
143569
  if (lineCount > COLLAPSE_THRESHOLD) {
@@ -143696,7 +143803,7 @@ var CreatePullRequestReview = type({
143696
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."
143697
143804
  ).optional(),
143698
143805
  approved: type.boolean.describe(
143699
- "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."
143700
143807
  ).optional(),
143701
143808
  commit_id: type.string.describe(
143702
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."
@@ -144041,7 +144148,8 @@ async function createAndSubmitWithFooter(ctx, params, opts) {
144041
144148
  const footer = buildPullfrogFooter({
144042
144149
  workflowRun: ctx.runId ? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId } : void 0,
144043
144150
  customParts,
144044
- model: ctx.toolState.model
144151
+ model: ctx.toolState.model,
144152
+ fallbackFrom: ctx.toolState.modelFallback?.from
144045
144153
  });
144046
144154
  return await ctx.octokit.rest.pulls.submitReview({
144047
144155
  owner: params.owner,
@@ -144540,8 +144648,8 @@ ${diffPreview}`);
144540
144648
  log.info(`\xBB checkout_pr({pull_number:${pull_number}}) already in flight \u2014 sharing result`);
144541
144649
  return inFlight;
144542
144650
  }
144543
- const current = ctx.toolState.issueNumber;
144544
- if (current !== void 0 && current !== pull_number) {
144651
+ const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }).trim();
144652
+ if (currentBranch !== `pr-${pull_number}`) {
144545
144653
  const dirty = $("git", ["status", "--porcelain"], { log: false }).trim();
144546
144654
  if (dirty) {
144547
144655
  throw new Error(
@@ -144874,9 +144982,8 @@ function GetIssueEventsTool(ctx) {
144874
144982
  });
144875
144983
  const relevantEventTypes = /* @__PURE__ */ new Set(["cross_referenced", "referenced"]);
144876
144984
  const parsedEvents = events.flatMap((event) => {
144877
- if (!("event" in event) || !relevantEventTypes.has(event.event)) {
144878
- return [];
144879
- }
144985
+ if (!("event" in event) || typeof event.event !== "string") return [];
144986
+ if (!relevantEventTypes.has(event.event)) return [];
144880
144987
  const baseEvent = {
144881
144988
  event: event.event
144882
144989
  };
@@ -145076,7 +145183,8 @@ function buildPrBodyWithFooter(ctx, body) {
145076
145183
  const footer = buildPullfrogFooter({
145077
145184
  triggeredBy: true,
145078
145185
  workflowRun: ctx.runId ? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId } : void 0,
145079
- model: ctx.toolState.model
145186
+ model: ctx.toolState.model,
145187
+ fallbackFrom: ctx.toolState.modelFallback?.from
145080
145188
  });
145081
145189
  const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
145082
145190
  return `${bodyWithoutFooter}${footer}`;
@@ -145647,7 +145755,9 @@ function ListPullRequestReviewsTool(ctx) {
145647
145755
  body: review.body,
145648
145756
  state: review.state,
145649
145757
  user: review.user?.login,
145650
- submitted_at: review.submitted_at
145758
+ submitted_at: review.submitted_at,
145759
+ commit_id: review.commit_id,
145760
+ html_url: review.html_url
145651
145761
  })),
145652
145762
  count: reviews.length
145653
145763
  };
@@ -145887,6 +145997,14 @@ function detectSandboxMethod() {
145887
145997
  return "none";
145888
145998
  }
145889
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(" ");
145890
146008
  function spawnShell(params) {
145891
146009
  const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
145892
146010
  const sandboxMethod = detectSandboxMethod();
@@ -145899,7 +146017,14 @@ function spawnShell(params) {
145899
146017
  if (sandboxMethod === "unshare") {
145900
146018
  return spawn2(
145901
146019
  "unshare",
145902
- ["--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
+ ],
145903
146028
  spawnOpts
145904
146029
  );
145905
146030
  }
@@ -145925,7 +146050,7 @@ function spawnShell(params) {
145925
146050
  "--mount-proc",
145926
146051
  "bash",
145927
146052
  "-c",
145928
- `${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}'`
145929
146054
  ],
145930
146055
  { ...spawnOpts, env: {} }
145931
146056
  );
@@ -146360,52 +146485,145 @@ Report findings clearly with file:line references and quoted evidence where poss
146360
146485
  // modes.ts
146361
146486
  var PR_SUMMARY_FORMAT = `### Default format
146362
146487
 
146363
- Follow this structure exactly:
146488
+ The body has at most three parts in this exact order:
146489
+
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.
146493
+
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.
146495
+
146496
+ ## 1. Reviewed changes preamble
146364
146497
 
146365
- <b>TL;DR</b> \u2014 1-3 sentences on what the PR does and why. Focus on intent, not mechanics.
146366
- NOTE: use HTML bold <b>TL;DR</b>, NOT markdown bold **TL;DR**.
146498
+ Open with a single bolded inline lead-in followed immediately by the bullet list (no \`### Key changes\` heading, no \`<b>TL;DR</b>\`):
146367
146499
 
146368
- ### Key changes
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
+ \`\`\`
146369
146527
 
146370
- - **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.
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\`.
146371
146529
 
146372
- <sub><b>Summary</b> \uFF5C {file_count} files \uFF5C {commit_count} commits \uFF5C base: \`{base}\` \u2190 \`{head}\`</sub>
146373
- NOTE: the metadata line goes AFTER the bullet list, not before it.
146530
+ ## 2. Cross-cutting issue sections (zero or more)
146374
146531
 
146375
- Then for each key change, a ## section with a short descriptive title that reads like a documentation heading (e.g. ## Live todo checklist tracking).
146532
+ For each cross-cutting concern, one \`### \` section. Use this exact shape:
146376
146533
 
146377
- <br/>
146534
+ \`\`\`
146535
+ ### {emoji} {short, descriptive title \u2014 what's wrong, not what to do}
146378
146536
 
146379
- ## Example readable section title
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.}
146380
146538
 
146381
- > **Before:** [old behavior/state]<br/>**After:** [new behavior/state]
146382
- 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.
146539
+ <details><summary>Technical details</summary>
146383
146540
 
146384
- 1-2 sentences of explanation. Break up text with tables, blockquotes, or lists \u2014 NEVER 3+ plain paragraphs in a row.
146541
+ \\\`\\\`\\\`\\\`markdown
146542
+ # {title repeated}
146385
146543
 
146386
- If a change warrants deeper explanation, use a blockquoted details/summary framed as a question:
146387
- > <details><summary>How does X work?</summary>
146388
- > Extended explanation here.
146389
- > </details>
146544
+ ## Affected sites
146545
+ - {file path:line} \u2014 {what's wrong there}
146546
+ - ...
146390
146547
 
146391
- End each section with a file links trail (3-4 key files max):
146392
- [\`file.ts\`](https://github.com/{owner}/{repo}/pull/{number}/files#diff-{sha256hex_of_filepath}) \xB7 ...
146548
+ ## Required outcome
146549
+ - {what the fix needs to achieve, not how to achieve it}
146550
+ - ...
146393
146551
 
146394
- Single-feature PRs: skip the ## sections. Fold before/after and explanation into the header after key changes.
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.}
146395
146554
 
146396
- CRITICAL \u2014 GitHub markdown rendering rule:
146397
- 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.
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
+ \\\`\\\`\\\`\\\`
146398
146558
 
146399
- Rules:
146400
- - \`##\` 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
146401
- - ALL variable names, identifiers, and file names in body text must be in backticks
146402
- - 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.
146403
- - Add <br/> before each ## heading for visual spacing. Do NOT use horizontal rules (---)
146404
- - Do NOT include raw diff stats like '+123 / -45' or line counts
146405
- - Do NOT include code blocks or repeat diff contents
146406
- - Do NOT include a changelog section \u2014 the key changes list serves this purpose
146407
- - Focus on *intent*, not *what* \u2014 the diff already shows what changed
146408
- - Get the file count and commit count from the checkout_pr metadata, not by counting manually`;
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.`;
146409
146627
  function computeModes(agentId) {
146410
146628
  const t = (toolName) => formatMcpToolRef(agentId, toolName);
146411
146629
  return [
@@ -146447,7 +146665,7 @@ function computeModes(agentId) {
146447
146665
 
146448
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.
146449
146667
 
146450
- 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.
146451
146669
 
146452
146670
  Delegation + research discipline (distilled from \`/anneal\` canonical \u2014 these are codified learnings from many review rounds, not theoretical best practices):
146453
146671
  - Do NOT summarize what you implemented \u2014 that biases the subagent toward validating the shape of your solution rather than questioning it.
@@ -146456,7 +146674,7 @@ function computeModes(agentId) {
146456
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.
146457
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.
146458
146676
 
146459
- 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 "..."\`).
146460
146678
 
146461
146679
  6. **finalize**:
146462
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)
@@ -146480,7 +146698,8 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
146480
146698
 
146481
146699
  4. For each comment:
146482
146700
  - understand the feedback
146483
- - 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.
146484
146703
  - if the request stands, make the code change using your native tools; otherwise reply explaining why
146485
146704
  - record what was done (or why nothing was done)
146486
146705
 
@@ -146488,11 +146707,13 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
146488
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
146489
146708
  - commit locally via shell (\`git add . && git commit -m "..."\`)
146490
146709
 
146491
- 6. Finalize:
146710
+ 6. Finalize. Reply + resolve are paired write actions: do BOTH or NEITHER for each thread.
146492
146711
  - confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
146493
- - 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)
146494
- - resolve addressed threads via \`${t("resolve_review_thread")}\`
146495
- - 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`
146496
146717
  },
146497
146718
  // Review and IncrementalReview use a 0-or-2+ lens pattern. The default is
146498
146719
  // 0 lenses (orchestrator handles the review solo). Multi-lens (2+
@@ -146509,9 +146730,12 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
146509
146730
  // the Review/IncrementalReview lens fan-out where independence between
146510
146731
  // perspectives is what's being purchased.
146511
146732
  //
146512
- // Deliberate omission vs canonical /anneal: severity categorization in
146513
- // the final message (the review body has its own CAUTION/IMPORTANT
146514
- // 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.
146515
146739
  {
146516
146740
  name: "Review",
146517
146741
  description: "Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
@@ -146597,7 +146821,9 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
146597
146821
 
146598
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.
146599
146823
 
146600
- 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.
146601
146827
 
146602
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.
146603
146829
 
@@ -146605,12 +146831,12 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
146605
146831
 
146606
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.
146607
146833
 
146608
- 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:
146609
146835
 
146610
146836
  - \`[!CAUTION]\` \u2014 large red banner. Reads as "this will break something."
146611
146837
  - \`[!IMPORTANT]\` \u2014 large purple banner. Reads as "you need to look at this before merging."
146612
- - \`[!NOTE]\` \u2014 small blue inline callout. Reads as "FYI, here's something worth noting."
146613
- - 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."
146614
146840
 
146615
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.
146616
146842
 
@@ -146619,25 +146845,25 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
146619
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):
146620
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\`.
146621
146847
  - **minor suggestions only** (single-line nits, doc/comment polish, defer-able observations, "rough edges"):
146622
- \`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.
146623
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):
146624
- \`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.
146625
146851
  - **no actionable issues**:
146626
- \`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.
146627
146853
 
146628
146854
  ${PR_SUMMARY_FORMAT}`
146629
146855
  },
146630
- // IncrementalReview shares Review's 0-or-2+ lens pattern but scopes the
146631
- // target to the incremental diff. The "issues must be NEW since the last
146632
- // Pullfrog review" filter lives at aggregation time (step 8), NOT in the
146633
- // subagent prompt pushing the filter into
146634
- // subagents matches the canonical anneal anti-pattern of "list known
146635
- // pre-existing failures — don't flag these" and suppresses signal on
146636
- // regressions the new commits amplified. The review body is just
146637
- // "Reviewed changes" — a separate "Prior review feedback" checklist
146638
- // would duplicate the rolling PR summary snapshot's record of what
146639
- // earlier runs already addressed and add noise to the user-facing
146640
- // 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.
146641
146867
  {
146642
146868
  name: "IncrementalReview",
146643
146869
  description: "Re-review a PR after new commits are pushed; focus on new changes since the last review",
@@ -146649,7 +146875,15 @@ ${PR_SUMMARY_FORMAT}`
146649
146875
 
146650
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.
146651
146877
 
146652
- 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.
146653
146887
 
146654
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.**
146655
146889
 
@@ -146695,22 +146929,28 @@ ${PR_SUMMARY_FORMAT}`
146695
146929
  - do NOT pre-shape their output with a finding schema
146696
146930
  - do NOT mention the other lenses (independence is the point)
146697
146931
 
146698
- 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.
146699
146935
 
146700
- 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.
146701
146939
 
146702
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.
146703
146941
 
146704
- 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.
146705
146943
 
146706
146944
  Follow these rules:
146707
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.
146708
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.
146709
- - 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.
146710
- - 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.
146711
- - 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.
146712
- - 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.
146713
- - 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}`
146714
146954
  },
146715
146955
  {
146716
146956
  name: "Plan",
@@ -146725,7 +146965,7 @@ ${PR_SUMMARY_FORMAT}`
146725
146965
 
146726
146966
  3. Produce a structured, actionable plan with clear milestones.
146727
146967
 
146728
- 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.`
146729
146969
  },
146730
146970
  {
146731
146971
  name: "Fix",
@@ -146817,6 +147057,7 @@ function initToolState(params) {
146817
147057
  return {
146818
147058
  progressComment: resolved,
146819
147059
  hadProgressComment: !!resolved,
147060
+ prepushFailureCount: 0,
146820
147061
  backgroundProcesses: /* @__PURE__ */ new Map(),
146821
147062
  usageEntries: []
146822
147063
  };
@@ -146916,6 +147157,17 @@ async function installFromNpmTarball(params) {
146916
147157
  // utils/providerErrors.ts
146917
147158
  var statusKey = `\\b(?:status[_ ]?code|http[_ ]?status|status)["']?\\s*[:=]\\s*["']?`;
146918
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" },
146919
147171
  // auth patterns must come BEFORE rate-limit patterns. OpenRouter 401 error
146920
147172
  // payloads carry `x-ratelimit-*` response headers in the dump, and the
146921
147173
  // free-form rate-limit regex below would otherwise win on word-boundary
@@ -147042,11 +147294,25 @@ function addSkill(params) {
147042
147294
  );
147043
147295
  if (result.status === 0) {
147044
147296
  log.success(`installed ${params.skill} skill (${params.agent})`);
147045
- } else {
147046
- const stderr = (result.stderr?.toString() || "").trim();
147047
- const errorMsg = result.error ? result.error.message : stderr;
147048
- log.info(`${params.skill} skill install failed: ${errorMsg}`);
147297
+ return;
147049
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")}`;
147050
147316
  }
147051
147317
 
147052
147318
  // utils/timer.ts
@@ -147143,7 +147409,7 @@ function buildUnsubmittedReviewPrompt(mode) {
147143
147409
  return [
147144
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.`,
147145
147411
  "",
147146
- "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.",
147147
147413
  "",
147148
147414
  "do NOT stop again until `create_pull_request_review` has been called successfully."
147149
147415
  ].join("\n");
@@ -147189,6 +147455,11 @@ function buildPostRunPrompt(issues) {
147189
147455
  if (issues.summaryStale) parts.push(buildSummaryStalePrompt(issues.summaryStale.filePath));
147190
147456
  return parts.join("\n\n---\n\n");
147191
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
+ }
147192
147463
  function buildLearningsReflectionPrompt(filePath) {
147193
147464
  return [
147194
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?`,
@@ -147200,18 +147471,16 @@ function buildLearningsReflectionPrompt(filePath) {
147200
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.`,
147201
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.`,
147202
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
+ "",
147203
147476
  `bullet hygiene:`,
147204
- `- one fact per line starting with \`- \`. each bullet is ONE specific durable fact, not a paragraph or essay.`,
147205
- `- 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.`,
147206
- `- 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.`,
147207
- `- 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.`,
147208
- `- 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.`,
147209
147482
  "",
147210
- `do NOT add bullets for:`,
147211
- `- 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.`,
147212
- `- 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.`,
147213
- `- 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.`,
147214
- `- 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.`,
147215
147484
  "",
147216
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.`
147217
147486
  ].join("\n");
@@ -147431,7 +147700,7 @@ function stripProviderPrefix(specifier) {
147431
147700
  function resolveEffort(_model) {
147432
147701
  return "high";
147433
147702
  }
147434
- function tailLines(text, maxCodeUnits) {
147703
+ function tailLines2(text, maxCodeUnits) {
147435
147704
  if (text.length <= maxCodeUnits) return text;
147436
147705
  const tail = text.slice(-maxCodeUnits);
147437
147706
  const firstNewline = tail.indexOf("\n");
@@ -147495,6 +147764,7 @@ async function runClaude(params) {
147495
147764
  }
147496
147765
  } else if (block.type === "tool_use") {
147497
147766
  const toolName = block.name || "unknown";
147767
+ suspendActivity();
147498
147768
  if (params.onToolUse) {
147499
147769
  params.onToolUse({
147500
147770
  toolName,
@@ -147539,6 +147809,7 @@ async function runClaude(params) {
147539
147809
  for (const block of content) {
147540
147810
  if (typeof block === "string") continue;
147541
147811
  if (block.type === "tool_result") {
147812
+ resumeActivity();
147542
147813
  timerFor(label).markToolResult();
147543
147814
  const outputContent = typeof block.content === "string" ? block.content : Array.isArray(block.content) ? block.content.map(
147544
147815
  (entry) => typeof entry === "string" ? entry : typeof entry === "object" && entry !== null && "text" in entry ? String(entry.text) : JSON.stringify(entry)
@@ -147627,6 +147898,7 @@ async function runClaude(params) {
147627
147898
  env: params.env,
147628
147899
  activityTimeout: 3e5,
147629
147900
  onActivityTimeout: params.onActivityTimeout,
147901
+ isPausedExternally: isActivitySuspended,
147630
147902
  stdio: ["ignore", "pipe", "pipe"],
147631
147903
  // run claude in its own process group so SIGKILL on activity timeout /
147632
147904
  // outer cancellation reaches any subprocesses it spawns (rg, file
@@ -147720,7 +147992,7 @@ ${stderrContext}`);
147720
147992
  const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
147721
147993
  const stdoutSnapshot = output.toString();
147722
147994
  const stderrSnapshot = recentStderr.join("\n");
147723
- const truncatedStdout = stdoutSnapshot ? tailLines(stdoutSnapshot, 2048) : "";
147995
+ const truncatedStdout = stdoutSnapshot ? tailLines2(stdoutSnapshot, 2048) : "";
147724
147996
  const nonJsonStdoutSnapshot = recentNonJsonStdout.join("\n");
147725
147997
  const errorMessage = lastResultError || stderrSnapshot || nonJsonStdoutSnapshot || truncatedStdout || `unknown error - no output from Claude CLI${errorContext}`;
147726
147998
  log.error(
@@ -147782,6 +148054,7 @@ ${stderrContext}`
147782
148054
  }
147783
148055
  var MANAGED_SETTINGS_DIR = "/etc/claude-code";
147784
148056
  var MANAGED_SETTINGS_PATH = `${MANAGED_SETTINGS_DIR}/managed-settings.json`;
148057
+ var CODEX_AUTH_DENY_PATH = "~/.local/share/opencode/auth.json";
147785
148058
  var managedSettings = {
147786
148059
  allowManagedPermissionRulesOnly: true,
147787
148060
  allowManagedHooksOnly: true,
@@ -147794,12 +148067,16 @@ var managedSettings = {
147794
148067
  "Edit(//proc/**)",
147795
148068
  "Edit(//sys/**)",
147796
148069
  "Glob(//proc/**)",
147797
- "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})`
147798
148075
  ]
147799
148076
  },
147800
148077
  sandbox: {
147801
148078
  filesystem: {
147802
- denyRead: ["/proc", "/sys"]
148079
+ denyRead: ["/proc", "/sys", CODEX_AUTH_DENY_PATH]
147803
148080
  }
147804
148081
  }
147805
148082
  };
@@ -147860,14 +148137,21 @@ var claude = agent({
147860
148137
  if (model) {
147861
148138
  baseArgs.push("--model", model);
147862
148139
  }
148140
+ const repoDir = process.cwd();
147863
148141
  const env2 = {
147864
148142
  ...process.env,
147865
- ...homeEnv
148143
+ ...homeEnv,
148144
+ PWD: repoDir
147866
148145
  };
147867
148146
  if (isBedrockRoute) {
147868
148147
  env2.CLAUDE_CODE_USE_BEDROCK = "1";
147869
148148
  }
147870
- 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
+ }
147871
148155
  log.info(`\xBB effort: ${effort}`);
147872
148156
  log.debug(`\xBB starting Pullfrog (Claude Code): node ${baseArgs.join(" ")}`);
147873
148157
  log.debug(`\xBB working directory: ${repoDir}`);
@@ -147887,7 +148171,7 @@ var claude = agent({
147887
148171
  ctx,
147888
148172
  initialResult: result,
147889
148173
  initialUsage: result.usage,
147890
- 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,
147891
148175
  canResume: (r) => Boolean(r.sessionId),
147892
148176
  resume: async (c) => {
147893
148177
  const sessionId = c.previousResult.sessionId;
@@ -147901,16 +148185,19 @@ var claude = agent({
147901
148185
  }
147902
148186
  });
147903
148187
 
147904
- // agents/opencode.ts
147905
- import { execFileSync as execFileSync4 } from "node:child_process";
147906
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync8 } from "node:fs";
147907
- import { join as join11 } from "node:path";
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";
147908
148192
  import { performance as performance7 } from "node:perf_hooks";
147909
148193
 
147910
148194
  // utils/agentHangReport.ts
147911
148195
  var MAX_STDERR_BYTES = 3e3;
147912
148196
  function formatAgentHangBody(input) {
147913
148197
  if (!input.diagnostic) return null;
148198
+ if (input.diagnostic.lastProviderError === "provider billing exhausted") {
148199
+ return formatBillingExhaustedBody(input.diagnostic);
148200
+ }
147914
148201
  const verb = input.isHang ? "stalled" : "failed";
147915
148202
  const cause = input.diagnostic.lastProviderError ? ` \u2014 likely cause: \`${input.diagnostic.lastProviderError}\`` : "";
147916
148203
  const headline = `**${input.diagnostic.label} ${verb}**${cause}`;
@@ -147968,6 +148255,97 @@ function pickFence(content) {
147968
148255
  }
147969
148256
  return "`".repeat(Math.max(3, max + 1));
147970
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
148290
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync8 } from "node:fs";
148291
+ import { homedir } from "node:os";
148292
+ import { join as join11 } from "node:path";
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
+ }
147971
148349
 
147972
148350
  // agents/opencodePlugin.ts
147973
148351
  var PULLFROG_BUS_EVENT_TYPE = "pullfrog_bus_event";
@@ -148050,6 +148428,9 @@ export default async function pullfrogEventsPlugin() {
148050
148428
  }
148051
148429
  `;
148052
148430
 
148431
+ // agents/opencodeShared.ts
148432
+ import { execFileSync as execFileSync4 } from "node:child_process";
148433
+
148053
148434
  // agents/subagentModels.ts
148054
148435
  function deriveSubagentModels(orchestratorSpec) {
148055
148436
  if (!orchestratorSpec) return { reviewer: void 0 };
@@ -148066,68 +148447,14 @@ function deriveSubagentModels(orchestratorSpec) {
148066
148447
  return { reviewer: void 0 };
148067
148448
  }
148068
148449
 
148069
- // agents/opencode.ts
148070
- async function installOpencodeCli() {
148071
- return await installFromNpmTarball({
148072
- packageName: "opencode-ai",
148073
- version: getDevDependencyVersion("opencode-ai"),
148074
- executablePath: "bin/opencode",
148075
- installDependencies: true
148076
- });
148077
- }
148078
- var GEMINI_3_DIRECT_THINKING_LEVEL = "medium";
148079
- var GEMINI_3_DIRECT_API_IDS = ["gemini-3.1-pro-preview", "gemini-3-flash-preview"];
148080
- function buildSecurityConfig(ctx, model) {
148081
- const config3 = {
148082
- permission: {
148083
- bash: "deny",
148084
- edit: "allow",
148085
- read: "allow",
148086
- webfetch: "allow",
148087
- external_directory: "allow",
148088
- skill: "allow"
148089
- },
148090
- mcp: {
148091
- [pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl }
148092
- },
148093
- agent: (() => {
148094
- const cfg = buildReviewerAgentConfig(model);
148095
- const reviewerModel = cfg[REVIEWER_AGENT_NAME]?.model ?? "(inherit)";
148096
- log.info(`\xBB subagent models: reviewfrog=${reviewerModel}`);
148097
- return cfg;
148098
- })(),
148099
- // opt into opencode's experimental `batch` tool (added in
148100
- // anomalyco/opencode PR #2983, opt-in via `experimental.batch_tool`). it
148101
- // exposes a single `batch` tool that runs 1-25 independent tool calls
148102
- // (read/grep/glob/bash/etc.) concurrently in one assistant turn, which
148103
- // collapses the dominant grep→20×read pattern into a single round trip.
148104
- // edits are explicitly disallowed inside the batch upstream. paired with
148105
- // the "Parallel tool execution" guidance in utils/instructions.ts so the
148106
- // model actually reaches for it. see wiki/prompt.md.
148107
- experimental: { batch_tool: true },
148108
- provider: {
148109
- google: {
148110
- models: Object.fromEntries(
148111
- GEMINI_3_DIRECT_API_IDS.map((id) => [
148112
- id,
148113
- {
148114
- options: {
148115
- thinkingConfig: { thinkingLevel: GEMINI_3_DIRECT_THINKING_LEVEL }
148116
- }
148117
- }
148118
- ])
148119
- )
148120
- }
148121
- }
148122
- };
148123
- if (model) {
148124
- config3.model = model;
148125
- const slashIndex = model.indexOf("/");
148126
- if (slashIndex > 0) {
148127
- config3.enabled_providers = [model.slice(0, slashIndex).toLowerCase()];
148128
- }
148129
- }
148130
- 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
+ );
148131
148458
  }
148132
148459
  function buildReviewerAgentConfig(orchestratorModel) {
148133
148460
  const overrides = deriveSubagentModels(orchestratorModel);
@@ -148140,6 +148467,15 @@ function buildReviewerAgentConfig(orchestratorModel) {
148140
148467
  }
148141
148468
  };
148142
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.";
148143
148479
  function getOpenCodeModels(cliPath) {
148144
148480
  try {
148145
148481
  const output = execFileSync4(cliPath, ["models"], {
@@ -148155,7 +148491,6 @@ function getOpenCodeModels(cliPath) {
148155
148491
  return [];
148156
148492
  }
148157
148493
  }
148158
- var AUTO_SELECT_WARNING = "select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this.";
148159
148494
  function autoSelectModel(cliPath) {
148160
148495
  const availableModels = getOpenCodeModels(cliPath);
148161
148496
  const availableSet = new Set(availableModels);
@@ -148176,6 +148511,58 @@ function autoSelectModel(cliPath) {
148176
148511
  log.warning(`\xBB no model resolved. letting OpenCode auto-select. ${AUTO_SELECT_WARNING}`);
148177
148512
  return void 0;
148178
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
+ }
148179
148566
  async function runOpenCode(params) {
148180
148567
  const startTime = performance7.now();
148181
148568
  let eventCount = 0;
@@ -148184,9 +148571,10 @@ async function runOpenCode(params) {
148184
148571
  let accumulatedCostUsd = 0;
148185
148572
  let tokensLogged = false;
148186
148573
  const toolCallTimings = /* @__PURE__ */ new Map();
148187
- let currentStepId = null;
148188
- let currentStepType = null;
148189
- let stepHistory = [];
148574
+ let lastEventAt = performance7.now();
148575
+ const recentStderr = [];
148576
+ let lastProviderError = null;
148577
+ let agentErrorEvent = null;
148190
148578
  const labeler = new SessionLabeler();
148191
148579
  function eventLabel(event) {
148192
148580
  const sid = event.sessionID ?? event.session_id;
@@ -148195,30 +148583,15 @@ async function runOpenCode(params) {
148195
148583
  function withLabel(label, message) {
148196
148584
  return label === ORCHESTRATOR_LABEL ? message : formatWithLabel(label, message);
148197
148585
  }
148198
- const thinkingTimers = /* @__PURE__ */ new Map();
148199
- function timerFor(label) {
148200
- let t = thinkingTimers.get(label);
148201
- if (!t) {
148202
- const formatLine = (line) => label === ORCHESTRATOR_LABEL ? line : formatWithLabel(label, line);
148203
- t = new ThinkingTimer(formatLine);
148204
- thinkingTimers.set(label, t);
148205
- }
148206
- return t;
148207
- }
148208
148586
  const taskDispatchByCallID = /* @__PURE__ */ new Map();
148209
- const pendingTaskDispatches = [];
148210
- const knownNonTaskCallIDs = /* @__PURE__ */ new Set();
148211
- function emitSubagentFinished(dispatch, status, output2, matchKind) {
148587
+ function emitSubagentFinished(dispatch, status, output2) {
148212
148588
  const subagentDuration = performance7.now() - dispatch.startedAt;
148213
148589
  const outputStr = typeof output2 === "string" ? output2 : "";
148214
148590
  const outputPreview = outputStr.length > 120 ? `${outputStr.slice(0, 120)}\u2026` : outputStr;
148215
- const matchSuffix = matchKind === "fifo" ? " [fifo-matched]" : "";
148216
148591
  log.info(
148217
- `\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, " ")}` : "")
148218
148593
  );
148219
148594
  taskDispatchByCallID.delete(dispatch.toolUseCallID);
148220
- const idx = pendingTaskDispatches.indexOf(dispatch);
148221
- if (idx >= 0) pendingTaskDispatches.splice(idx, 1);
148222
148595
  }
148223
148596
  function buildUsage() {
148224
148597
  const totalInput = accumulatedTokens.input + accumulatedTokens.cacheRead + accumulatedTokens.cacheWrite;
@@ -148232,55 +148605,6 @@ async function runOpenCode(params) {
148232
148605
  } : void 0;
148233
148606
  }
148234
148607
  const handlers2 = {
148235
- init: (event) => {
148236
- const label = labeler.labelFor(event.session_id ?? null);
148237
- log.debug(
148238
- withLabel(
148239
- label,
148240
- `\xBB ${params.label} init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}`
148241
- )
148242
- );
148243
- log.debug(withLabel(label, `\xBB ${params.label} init event (full): ${JSON.stringify(event)}`));
148244
- if (label === ORCHESTRATOR_LABEL) {
148245
- finalOutput = "";
148246
- accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
148247
- accumulatedCostUsd = 0;
148248
- tokensLogged = false;
148249
- } else {
148250
- log.info(`\xBB ${params.label} subagent init: ${label} (session ${event.session_id || "?"})`);
148251
- }
148252
- },
148253
- message: (event) => {
148254
- const label = eventLabel(event);
148255
- if (event.role === "assistant" && event.content?.trim()) {
148256
- const message = event.content.trim();
148257
- if (event.delta) {
148258
- log.debug(
148259
- withLabel(
148260
- label,
148261
- `\xBB ${params.label} thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}`
148262
- )
148263
- );
148264
- } else {
148265
- log.debug(
148266
- withLabel(
148267
- label,
148268
- `\xBB ${params.label} message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}`
148269
- )
148270
- );
148271
- if (label === ORCHESTRATOR_LABEL) {
148272
- finalOutput = message;
148273
- }
148274
- }
148275
- } else if (event.role === "user") {
148276
- log.debug(
148277
- withLabel(
148278
- label,
148279
- `\xBB ${params.label} message (${event.role}): ${event.content?.substring(0, 100) || ""}${event.content && event.content.length > 100 ? "..." : ""}`
148280
- )
148281
- );
148282
- }
148283
- },
148284
148608
  text: (event) => {
148285
148609
  if (event.part?.text?.trim()) {
148286
148610
  const message = event.part.text.trim();
@@ -148292,119 +148616,90 @@ async function runOpenCode(params) {
148292
148616
  }
148293
148617
  }
148294
148618
  },
148295
- step_start: (event) => {
148296
- const stepType = event.part?.type || "unknown";
148297
- const stepId = event.part?.id || "unknown";
148298
- currentStepId = stepId;
148299
- currentStepType = stepType;
148300
- 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: () => {
148301
148640
  },
148302
- step_finish: async (event) => {
148303
- const stepId = event.part?.id || "unknown";
148304
- const eventTokens = event.part?.tokens;
148305
- if (eventTokens) {
148306
- accumulatedTokens.input += eventTokens.input || 0;
148307
- accumulatedTokens.output += eventTokens.output || 0;
148308
- accumulatedTokens.cacheRead += eventTokens.cache?.read || 0;
148309
- 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;
148310
148648
  }
148311
148649
  if (typeof event.part?.cost === "number" && Number.isFinite(event.part.cost)) {
148312
148650
  accumulatedCostUsd += event.part.cost;
148313
148651
  }
148314
- if (currentStepId === stepId) {
148315
- currentStepId = null;
148316
- currentStepType = null;
148317
- }
148318
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
+ */
148319
148658
  tool_use: (event) => {
148320
148659
  const toolName = event.part?.tool;
148321
148660
  const toolId = event.part?.callID;
148661
+ const state = event.part?.state;
148322
148662
  if (!toolName || !toolId) {
148323
148663
  log.info(
148324
148664
  `\xBB tool_use event missing toolName or toolId: ${JSON.stringify(event).substring(0, 500)}`
148325
148665
  );
148326
148666
  return;
148327
148667
  }
148328
- if (toolName === "task") {
148329
- if (!taskDispatchByCallID.has(toolId)) {
148330
- const taskInput = event.part?.state?.input ?? {};
148331
- const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
148332
- const dispatch = {
148333
- label: dispatchedLabel,
148334
- startedAt: performance7.now(),
148335
- toolUseCallID: toolId
148336
- };
148337
- taskDispatchByCallID.set(toolId, dispatch);
148338
- pendingTaskDispatches.push(dispatch);
148339
- log.info(
148340
- `\xBB dispatching subagent: ${dispatchedLabel}` + (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
148341
- );
148342
- }
148343
- } else {
148344
- knownNonTaskCallIDs.add(toolId);
148345
- }
148668
+ const status = state?.status;
148669
+ const isTerminal2 = status === "completed" || status === "error";
148346
148670
  const label = eventLabel(event);
148347
- if (stepHistory.length > 0) {
148348
- stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
148349
- }
148350
- if (params.onToolUse) {
148351
- params.onToolUse({
148352
- toolName,
148353
- 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
148354
148678
  });
148679
+ log.info(
148680
+ `\xBB dispatching subagent: ${dispatchedLabel}` + (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
148681
+ );
148355
148682
  }
148356
- timerFor(label).markToolCall();
148357
- const inputFormatted = formatJsonValue(event.part?.state?.input || {});
148358
- const toolCallLine = inputFormatted !== "{}" ? `\xBB ${toolName}(${inputFormatted})` : `\xBB ${toolName}()`;
148359
- log.info(withLabel(label, toolCallLine));
148360
- if (event.part?.state?.status === "completed" && event.part.state.output) {
148361
- log.debug(withLabel(label, ` output: ${event.part.state.output}`));
148362
- }
148363
- if (event.part?.state?.status === "error") {
148364
- log.info(withLabel(label, `\xBB tool call failed: ${event.part.state.error}`));
148365
- }
148366
- if (toolName.includes("report_progress") && params.todoTracker) {
148367
- log.debug("\xBB report_progress detected, disabling todo tracking");
148368
- params.todoTracker.cancel();
148683
+ params.onToolUse?.({ toolName, input: state?.input });
148684
+ if (!toolCallTimings.has(toolId)) {
148685
+ toolCallTimings.set(toolId, performance7.now());
148369
148686
  }
148370
- if (toolName === "todowrite" && params.todoTracker?.enabled) {
148371
- params.todoTracker.update(event.part?.state?.input);
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}`));
148372
148692
  }
148373
- },
148374
- tool_result: (event) => {
148375
- const toolId = event.part?.callID || event.tool_id;
148376
- const state = event.part?.state;
148377
- const status = state?.status ?? event.status ?? "unknown";
148378
- const payload = state?.status === "completed" ? state.output : state?.status === "error" ? state.error : event.output;
148379
- const label = eventLabel(event);
148380
- timerFor(label).markToolResult();
148381
- if (taskDispatchByCallID.size > 0 || pendingTaskDispatches.length > 0) {
148382
- if (toolId && taskDispatchByCallID.has(toolId)) {
148383
- const dispatch = taskDispatchByCallID.get(toolId);
148384
- if (dispatch) emitSubagentFinished(dispatch, status, payload, "exact");
148385
- } else {
148386
- const callIDIsKnownNonTask = toolId ? knownNonTaskCallIDs.has(toolId) : false;
148387
- if (!callIDIsKnownNonTask && pendingTaskDispatches.length > 0) {
148388
- const dispatch = pendingTaskDispatches[0];
148389
- emitSubagentFinished(dispatch, status, payload, "fifo");
148390
- }
148391
- }
148693
+ if (state?.status === "error") {
148694
+ log.info(withLabel(label, `\xBB tool call failed: ${state.error}`));
148392
148695
  }
148393
- if (toolId) {
148696
+ if (isTerminal2) {
148697
+ const dispatch = toolName === "task" ? taskDispatchByCallID.get(toolId) : void 0;
148698
+ if (dispatch) emitSubagentFinished(dispatch, status, terminalPayload(state));
148394
148699
  const toolStartTime = toolCallTimings.get(toolId);
148395
- if (toolStartTime) {
148700
+ if (toolStartTime !== void 0) {
148396
148701
  const toolDuration = performance7.now() - toolStartTime;
148397
148702
  toolCallTimings.delete(toolId);
148398
- const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : "";
148399
- log.debug(
148400
- withLabel(
148401
- label,
148402
- `\xBB ${params.label} tool_result${stepContext}: id=${toolId}, status=${status}, duration=${Math.round(toolDuration)}ms`
148403
- )
148404
- );
148405
- if (payload) {
148406
- log.debug(withLabel(label, ` output: ${payload}`));
148407
- }
148408
148703
  if (toolDuration > 5e3) {
148409
148704
  log.info(
148410
148705
  withLabel(
@@ -148415,10 +148710,12 @@ async function runOpenCode(params) {
148415
148710
  }
148416
148711
  }
148417
148712
  }
148418
- if (status === "error") {
148419
- log.info(withLabel(label, `\xBB tool call failed: ${payload ?? "(no error message)"}`));
148420
- } else if (payload) {
148421
- log.debug(withLabel(label, `tool output: ${payload}`));
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);
148422
148719
  }
148423
148720
  },
148424
148721
  error: (event) => {
@@ -148427,23 +148724,18 @@ async function runOpenCode(params) {
148427
148724
  const errorMessage = event.error?.data?.message || event.error?.name || JSON.stringify(event);
148428
148725
  log.info(`\xBB ${params.label} error event: ${errorName}: ${errorMessage}`);
148429
148726
  },
148430
- result: async (event) => {
148431
- const status = event.status || "unknown";
148432
- const duration4 = event.stats?.duration_ms || 0;
148433
- const toolCalls = event.stats?.tool_calls || 0;
148434
- log.info(
148435
- `\xBB ${params.label} result: status=${status}, duration=${duration4}ms, tool_calls=${toolCalls}`
148436
- );
148437
- if (event.status === "error") {
148438
- log.info(`\xBB ${params.label} failed: ${JSON.stringify(event)}`);
148439
- } else {
148440
- log.info(`\xBB run complete: tool_calls=${toolCalls}, duration=${duration4}ms`);
148441
- if ((accumulatedTokens.input > 0 || accumulatedTokens.output > 0 || accumulatedTokens.cacheRead > 0 || accumulatedTokens.cacheWrite > 0) && !tokensLogged) {
148442
- logTokenTable({ ...accumulatedTokens, costUsd: accumulatedCostUsd });
148443
- tokensLogged = true;
148444
- }
148445
- }
148446
- },
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
+ */
148447
148739
  [PULLFROG_BUS_EVENT_TYPE]: async (event) => {
148448
148740
  const busEvent = event.bus_event;
148449
148741
  if (!busEvent || busEvent.type !== "message.part.updated") return;
@@ -148453,20 +148745,15 @@ async function runOpenCode(params) {
148453
148745
  const partType = part.type;
148454
148746
  if (partType === "tool") {
148455
148747
  const status = part.state?.status;
148456
- const partWithToolFields = part;
148457
- const isOrchestratorTaskDispatch = partWithToolFields.tool === "task" && status === "running";
148458
- if (isOrchestratorTaskDispatch) {
148459
- const callID = partWithToolFields.callID;
148460
- if (typeof callID === "string" && !taskDispatchByCallID.has(callID)) {
148461
- 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 ?? {};
148462
148751
  const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
148463
- const dispatch = {
148752
+ taskDispatchByCallID.set(part.callID, {
148464
148753
  label: dispatchedLabel,
148465
148754
  startedAt: performance7.now(),
148466
- toolUseCallID: callID
148467
- };
148468
- taskDispatchByCallID.set(callID, dispatch);
148469
- pendingTaskDispatches.push(dispatch);
148755
+ toolUseCallID: part.callID
148756
+ });
148470
148757
  log.info(
148471
148758
  `\xBB dispatching subagent: ${dispatchedLabel}` + (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
148472
148759
  );
@@ -148474,27 +148761,19 @@ async function runOpenCode(params) {
148474
148761
  return;
148475
148762
  }
148476
148763
  if (status !== "completed" && status !== "error") return;
148477
- await handlers2.tool_use({
148478
- type: "tool_use",
148479
- sessionID,
148480
- part
148481
- });
148764
+ await handlers2.tool_use({ type: "tool_use", sessionID, part });
148482
148765
  return;
148483
148766
  }
148484
148767
  if (partType === "step-start" || partType === "step-finish") return;
148485
148768
  if (partType === "text" && part.time?.end !== void 0) {
148486
- await handlers2.text({
148487
- type: "text",
148488
- sessionID,
148489
- part
148490
- });
148769
+ handlers2.text({ type: "text", sessionID, part });
148491
148770
  return;
148492
148771
  }
148772
+ if (partType === "reasoning" && part.time?.end !== void 0) {
148773
+ handlers2.reasoning({ type: "reasoning", sessionID, part });
148774
+ }
148493
148775
  }
148494
148776
  };
148495
- const recentStderr = [];
148496
- let lastProviderError = null;
148497
- let agentErrorEvent = null;
148498
148777
  const diagnostic = {
148499
148778
  label: params.label,
148500
148779
  recentStderr,
@@ -148552,15 +148831,15 @@ async function runOpenCode(params) {
148552
148831
  eventCount++;
148553
148832
  diagnostic.eventCount = eventCount;
148554
148833
  log.debug(JSON.stringify(event, null, 2));
148555
- const timeSinceLastActivity = getIdleMs();
148556
- if (timeSinceLastActivity > 1e4) {
148834
+ const idleMs = performance7.now() - lastEventAt;
148835
+ if (idleMs > 1e4) {
148557
148836
  const activeToolCalls = toolCallTimings.size;
148558
148837
  const toolCallInfo = activeToolCalls > 0 ? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})` : ` (${params.label} may be processing internally - LLM calls, planning, etc.)`;
148559
148838
  log.info(
148560
- `\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)`
148561
148840
  );
148562
148841
  }
148563
- markActivity();
148842
+ lastEventAt = performance7.now();
148564
148843
  const handler2 = handlers2[event.type];
148565
148844
  if (!handler2) {
148566
148845
  log.info(
@@ -148597,14 +148876,13 @@ async function runOpenCode(params) {
148597
148876
  } else {
148598
148877
  params.todoTracker?.cancel();
148599
148878
  }
148600
- if (pendingTaskDispatches.length > 0) {
148601
- for (const dispatch of [...pendingTaskDispatches]) {
148879
+ if (taskDispatchByCallID.size > 0) {
148880
+ for (const dispatch of taskDispatchByCallID.values()) {
148602
148881
  const elapsed = performance7.now() - dispatch.startedAt;
148603
148882
  log.info(
148604
- `\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`
148605
148884
  );
148606
148885
  }
148607
- pendingTaskDispatches.length = 0;
148608
148886
  taskDispatchByCallID.clear();
148609
148887
  }
148610
148888
  const duration4 = performance7.now() - startTime;
@@ -148687,22 +148965,22 @@ ${stderrContext}`
148687
148965
  }
148688
148966
  var opencode = agent({
148689
148967
  name: "opencode",
148690
- install: installOpencodeCli,
148968
+ install: installCli,
148691
148969
  run: async (ctx) => {
148692
- const cliPath = await installOpencodeCli();
148970
+ const cliPath = await installCli();
148693
148971
  const rawModel = ctx.payload.proxyModel ?? ctx.resolvedModel ?? autoSelectModel(cliPath);
148694
148972
  const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
148695
148973
  const isBedrockRoute = rawModel !== void 0 && bedrockModelId !== void 0 && bedrockModelId === rawModel;
148696
148974
  const model = isBedrockRoute ? `amazon-bedrock/${rawModel}` : rawModel;
148697
148975
  const homeEnv = {
148698
148976
  HOME: ctx.tmpdir,
148699
- XDG_CONFIG_HOME: join11(ctx.tmpdir, ".config")
148977
+ XDG_CONFIG_HOME: join12(ctx.tmpdir, ".config")
148700
148978
  };
148701
- mkdirSync5(join11(homeEnv.XDG_CONFIG_HOME, "opencode"), { recursive: true });
148702
- const opencodePluginDir = join11(homeEnv.XDG_CONFIG_HOME, "opencode", "plugin");
148703
- mkdirSync5(opencodePluginDir, { recursive: true });
148704
- writeFileSync8(
148705
- 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),
148706
148984
  PULLFROG_OPENCODE_PLUGIN_SOURCE
148707
148985
  );
148708
148986
  const agentBrowserVersion = getDevDependencyVersion("agent-browser");
@@ -148713,18 +148991,32 @@ var opencode = agent({
148713
148991
  agent: "opencode"
148714
148992
  });
148715
148993
  installBundledSkills({ home: homeEnv.HOME });
148716
- const baseArgs = ["run", "--format", "json", "--print-logs"];
148994
+ const codexAuth = installCodexAuth();
148995
+ const baseArgs = ["run", "--format", "json", "--print-logs", "--thinking"];
148717
148996
  const permissionOverride = JSON.stringify({
148718
148997
  external_directory: { "*": "deny", "/tmp/*": "allow" }
148719
148998
  });
148999
+ const repoDir = process.cwd();
148720
149000
  const env2 = {
148721
149001
  ...process.env,
148722
149002
  ...homeEnv,
149003
+ PWD: repoDir,
148723
149004
  OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
148724
149005
  OPENCODE_PERMISSION: permissionOverride,
148725
149006
  GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY
148726
149007
  };
148727
- 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
+ }
148728
149020
  log.debug(`\xBB starting Pullfrog (OpenCode): ${cliPath} ${baseArgs.join(" ")}`);
148729
149021
  log.debug(`\xBB working directory: ${repoDir}`);
148730
149022
  const runParams = {
@@ -148745,7 +149037,7 @@ var opencode = agent({
148745
149037
  ctx,
148746
149038
  initialResult: result,
148747
149039
  initialUsage: result.usage,
148748
- 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,
148749
149041
  resume: async (c) => runOpenCode({
148750
149042
  ...runParams,
148751
149043
  args: [...baseArgs, "--continue", c.prompt]
@@ -148819,7 +149111,9 @@ function resolveAgent(ctx) {
148819
149111
  }
148820
149112
 
148821
149113
  // utils/apiKeys.ts
148822
- 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
+ );
148823
149117
  var MISSING_KEY_MARKER = "no API key found";
148824
149118
  function buildMissingApiKeyError(params) {
148825
149119
  const githubSecretsUrl = `https://github.com/${params.owner}/${params.name}/settings/secrets/actions`;
@@ -148848,6 +149142,11 @@ function hasEnvVar2(name) {
148848
149142
  const value2 = process.env[name];
148849
149143
  return typeof value2 === "string" && value2.length > 0;
148850
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
+ }
148851
149150
  function validateBedrockSetup(params) {
148852
149151
  const hasAuth = hasEnvVar2("AWS_BEARER_TOKEN_BEDROCK") || hasEnvVar2("AWS_ACCESS_KEY_ID") && hasEnvVar2("AWS_SECRET_ACCESS_KEY");
148853
149152
  const missing = [];
@@ -148882,7 +149181,7 @@ function validateAgentApiKey(params) {
148882
149181
  }
148883
149182
  function isApiKeyAuthError(text) {
148884
149183
  if (!text) return false;
148885
- 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);
148886
149185
  }
148887
149186
  function formatApiKeyErrorSummary(params) {
148888
149187
  if (params.raw.includes(MISSING_KEY_MARKER)) {
@@ -148994,11 +149293,137 @@ async function fetchBodyHtml(ctx) {
148994
149293
  }
148995
149294
  }
148996
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
+
148997
149422
  // utils/github.ts
148998
- var core2 = __toESM(require_core(), 1);
149423
+ var core3 = __toESM(require_core(), 1);
148999
149424
  import { createSign } from "node:crypto";
149000
149425
  import { rename, writeFile } from "node:fs/promises";
149001
- import { dirname as dirname3, join as join12 } from "node:path";
149426
+ import { dirname as dirname3, join as join14 } from "node:path";
149002
149427
 
149003
149428
  // node_modules/.pnpm/@octokit+plugin-throttling@11.0.3_@octokit+core@7.0.5/node_modules/@octokit/plugin-throttling/dist-bundle/index.js
149004
149429
  var import_light = __toESM(require_light(), 1);
@@ -152657,7 +153082,7 @@ var TokenExchangeError = class extends Error {
152657
153082
  }
152658
153083
  };
152659
153084
  async function acquireTokenViaOIDC(opts) {
152660
- const oidcToken = await core2.getIDToken("pullfrog-api");
153085
+ const oidcToken = await core3.getIDToken("pullfrog-api");
152661
153086
  const repos = [...opts?.repos ?? []];
152662
153087
  const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
152663
153088
  if (targetRepo) {
@@ -152815,9 +153240,13 @@ async function acquireNewToken(opts) {
152815
153240
  return error49 instanceof Error && (error49.message.includes("timed out") || error49.message.includes("fetch failed") || error49.message.includes("ECONNRESET") || error49.message.includes("ETIMEDOUT"));
152816
153241
  }
152817
153242
  });
152818
- } else {
152819
- return await acquireTokenViaGitHubApp(opts);
152820
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);
152821
153250
  }
152822
153251
  function parseRepoContext() {
152823
153252
  const githubRepo = process.env.GITHUB_REPOSITORY;
@@ -152852,7 +153281,7 @@ function getGitHubUsageSummary() {
152852
153281
  }
152853
153282
  async function writeGitHubUsageSummaryToFile(path3) {
152854
153283
  const summary2 = getGitHubUsageSummary();
152855
- const tmpPath = join12(dirname3(path3), `.usage-summary-${process.pid}.tmp`);
153284
+ const tmpPath = join14(dirname3(path3), `.usage-summary-${process.pid}.tmp`);
152856
153285
  await writeFile(tmpPath, JSON.stringify(summary2));
152857
153286
  await rename(tmpPath, path3);
152858
153287
  }
@@ -152902,253 +153331,6 @@ function createOctokit(token) {
152902
153331
  return octokit;
152903
153332
  }
152904
153333
 
152905
- // utils/token.ts
152906
- var core3 = __toESM(require_core(), 1);
152907
- import assert2 from "node:assert/strict";
152908
- var mcpTokenValue;
152909
- function getJobToken() {
152910
- const inputToken = core3.getInput("token");
152911
- if (inputToken) {
152912
- return inputToken;
152913
- }
152914
- const fallbackToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
152915
- if (fallbackToken) {
152916
- return fallbackToken;
152917
- }
152918
- throw new Error("token input is required");
152919
- }
152920
- async function resolveTokens(params) {
152921
- assert2(!mcpTokenValue, "tokens are already resolved");
152922
- const externalToken = process.env.GH_TOKEN;
152923
- if (externalToken) {
152924
- mcpTokenValue = externalToken;
152925
- if (isGitHubActions) {
152926
- core3.setSecret(externalToken);
152927
- }
152928
- log.info("\xBB using external GH_TOKEN for both git and MCP");
152929
- return {
152930
- gitToken: externalToken,
152931
- mcpToken: externalToken,
152932
- async [Symbol.asyncDispose]() {
152933
- mcpTokenValue = void 0;
152934
- }
152935
- };
152936
- }
152937
- const gitPermissions = params.push === "disabled" ? { contents: "read" } : { contents: "write", workflows: "write" };
152938
- const gitToken = await acquireNewToken({ permissions: gitPermissions });
152939
- if (isGitHubActions) {
152940
- core3.setSecret(gitToken);
152941
- }
152942
- log.info(
152943
- `\xBB acquired git token (${Object.entries(gitPermissions).map((e) => e.join(":")).join(", ")})`
152944
- );
152945
- const mcpPermissions = {
152946
- contents: "write",
152947
- pull_requests: "write",
152948
- issues: "write",
152949
- checks: "read",
152950
- actions: "read"
152951
- };
152952
- const mcpToken = await acquireNewToken({ permissions: mcpPermissions });
152953
- if (isGitHubActions) {
152954
- core3.setSecret(mcpToken);
152955
- }
152956
- log.info(
152957
- `\xBB acquired scoped MCP token (${Object.entries(mcpPermissions).map((e) => e.join(":")).join(", ")})`
152958
- );
152959
- mcpTokenValue = mcpToken;
152960
- let disposingRef;
152961
- const dispose = async () => {
152962
- if (disposingRef) {
152963
- return disposingRef.promise;
152964
- }
152965
- disposingRef = Promise.withResolvers();
152966
- try {
152967
- mcpTokenValue = void 0;
152968
- await Promise.all([
152969
- revokeGitHubInstallationToken(gitToken),
152970
- revokeGitHubInstallationToken(mcpToken)
152971
- ]);
152972
- } finally {
152973
- removeSignalHandler();
152974
- disposingRef.resolve();
152975
- disposingRef = void 0;
152976
- }
152977
- };
152978
- const removeSignalHandler = onExitSignal(dispose);
152979
- return {
152980
- gitToken,
152981
- mcpToken,
152982
- [Symbol.asyncDispose]: dispose
152983
- };
152984
- }
152985
- function getGitHubInstallationToken() {
152986
- assert2(mcpTokenValue, "tokens not set. call resolveTokens first.");
152987
- return mcpTokenValue;
152988
- }
152989
- async function revokeGitHubInstallationToken(token) {
152990
- const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
152991
- try {
152992
- await fetch(`${apiUrl}/installation/token`, {
152993
- method: "DELETE",
152994
- headers: {
152995
- Accept: "application/vnd.github+json",
152996
- Authorization: `Bearer ${token}`,
152997
- "X-GitHub-Api-Version": "2022-11-28"
152998
- }
152999
- });
153000
- log.debug("\xBB installation token revoked");
153001
- } catch (error49) {
153002
- log.info(
153003
- `Failed to revoke installation token: ${error49 instanceof Error ? error49.message : String(error49)}`
153004
- );
153005
- }
153006
- }
153007
-
153008
- // utils/errorReport.ts
153009
- async function reportErrorToComment(ctx) {
153010
- const formattedError = ctx.title ? `${ctx.title}
153011
-
153012
- ${ctx.error}` : ctx.error;
153013
- const comment = ctx.toolState.progressComment;
153014
- if (!comment) {
153015
- return;
153016
- }
153017
- const repoContext = parseRepoContext();
153018
- const octokit = createOctokit(getGitHubInstallationToken());
153019
- const runId = process.env.GITHUB_RUN_ID ? Number.parseInt(process.env.GITHUB_RUN_ID, 10) : void 0;
153020
- const customParts = [];
153021
- if (runId) {
153022
- const apiUrl = getApiUrl();
153023
- customParts.push(
153024
- `[Rerun failed job \u2794](${apiUrl}/trigger/${repoContext.owner}/${repoContext.name}/${runId}?action=rerun)`
153025
- );
153026
- }
153027
- const footer = buildPullfrogFooter({
153028
- triggeredBy: true,
153029
- workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : void 0,
153030
- customParts,
153031
- model: ctx.toolState.model
153032
- });
153033
- await updateProgressComment(
153034
- { octokit, owner: repoContext.owner, repo: repoContext.name },
153035
- comment,
153036
- `${formattedError}${footer}`
153037
- );
153038
- ctx.toolState.wasUpdated = true;
153039
- }
153040
-
153041
- // utils/gitAuthServer.ts
153042
- import { randomUUID as randomUUID3 } from "node:crypto";
153043
- import { writeFileSync as writeFileSync9 } from "node:fs";
153044
- import { createServer as createServer2 } from "node:http";
153045
- import { join as join13 } from "node:path";
153046
- var CODE_TTL_MS = 5 * 60 * 1e3;
153047
- var TAMPER_WINDOW_MS = 6e4;
153048
- function revokeGitHubToken(token) {
153049
- fetch("https://api.github.com/installation/token", {
153050
- method: "DELETE",
153051
- headers: {
153052
- Authorization: `Bearer ${token}`,
153053
- Accept: "application/vnd.github+json",
153054
- "User-Agent": "pullfrog"
153055
- }
153056
- }).then(
153057
- (r) => log.info(`token revocation response: ${r.status}`),
153058
- () => log.warning("token revocation request failed")
153059
- );
153060
- }
153061
- async function startGitAuthServer(tmpdir3) {
153062
- const codes = /* @__PURE__ */ new Map();
153063
- const server = createServer2((req, res) => {
153064
- if (req.method !== "GET") {
153065
- res.writeHead(405).end();
153066
- return;
153067
- }
153068
- const code = req.url?.slice(1);
153069
- if (!code) {
153070
- res.writeHead(400).end();
153071
- return;
153072
- }
153073
- const entry = codes.get(code);
153074
- if (!entry) {
153075
- res.writeHead(404).end();
153076
- return;
153077
- }
153078
- if (entry.state === "pending") {
153079
- entry.state = "consumed";
153080
- clearTimeout(entry.timeout);
153081
- entry.timeout = setTimeout(() => codes.delete(code), TAMPER_WINDOW_MS);
153082
- entry.timeout.unref();
153083
- res.writeHead(200, { "Content-Type": "text/plain" });
153084
- res.end(entry.token);
153085
- return;
153086
- }
153087
- log.info("askpass code used twice \u2014 revoking token");
153088
- revokeGitHubToken(entry.token);
153089
- clearTimeout(entry.timeout);
153090
- codes.delete(code);
153091
- res.writeHead(409, { "Content-Type": "text/plain" });
153092
- res.end("compromised");
153093
- });
153094
- await new Promise((resolve3, reject) => {
153095
- server.on("error", reject);
153096
- server.listen(0, "127.0.0.1", () => resolve3());
153097
- });
153098
- const rawAddr = server.address();
153099
- if (!rawAddr || typeof rawAddr === "string") {
153100
- throw new Error("git auth server failed to bind");
153101
- }
153102
- const port = rawAddr.port;
153103
- log.debug(`git auth server listening on 127.0.0.1:${port}`);
153104
- function register4(token) {
153105
- const code = randomUUID3();
153106
- const timeout = setTimeout(() => {
153107
- codes.delete(code);
153108
- log.debug(`git auth code expired: ${code.slice(0, 8)}...`);
153109
- }, CODE_TTL_MS);
153110
- timeout.unref();
153111
- codes.set(code, { token, state: "pending", timeout });
153112
- return code;
153113
- }
153114
- function writeAskpassScript(code) {
153115
- const scriptId = randomUUID3();
153116
- const scriptName = `askpass-${scriptId}.js`;
153117
- const scriptPath = join13(tmpdir3, scriptName);
153118
- const content = [
153119
- `#!/usr/bin/env node`,
153120
- `var a=process.argv[2]||"";`,
153121
- `if(/^Username/i.test(a)){process.stdout.write("x-access-token\\n")}`,
153122
- `else{var h=require("http");`,
153123
- `h.get("http://127.0.0.1:${port}/${code}",function(r){`,
153124
- `if(r.statusCode===409){process.stderr.write("askpass-compromised\\n");process.exit(1)}`,
153125
- `if(r.statusCode!==200){process.exit(1)}`,
153126
- `var d="";r.on("data",function(c){d+=c});`,
153127
- `r.on("end",function(){`,
153128
- `process.stdout.write(d+"\\n");`,
153129
- `try{require("fs").unlinkSync("${scriptPath.replace(/\\/g, "\\\\")}")}catch(e){}`,
153130
- `})}).on("error",function(){process.exit(1)})}`
153131
- ].join("\n");
153132
- writeFileSync9(scriptPath, content, { mode: 448 });
153133
- return scriptPath;
153134
- }
153135
- async function close() {
153136
- for (const entry of codes.values()) {
153137
- clearTimeout(entry.timeout);
153138
- }
153139
- codes.clear();
153140
- await new Promise((resolve3) => server.close(() => resolve3()));
153141
- log.debug("git auth server closed");
153142
- }
153143
- return {
153144
- port,
153145
- register: register4,
153146
- writeAskpassScript,
153147
- close,
153148
- [Symbol.asyncDispose]: close
153149
- };
153150
- }
153151
-
153152
153334
  // utils/instructions.ts
153153
153335
  import { execSync as execSync2 } from "node:child_process";
153154
153336
  function buildRuntimeContext(ctx) {
@@ -153341,7 +153523,7 @@ Rules:
153341
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\`).
153342
153524
  - Never add co-author trailers (e.g., "Co-authored-by" or "Co-Authored-By") to commit messages.
153343
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.
153344
- - \`${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.
153345
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.
153346
153528
 
153347
153529
  ### GitHub
@@ -153369,11 +153551,9 @@ For maximum efficiency, whenever you need to perform multiple independent operat
153369
153551
  - listing multiple directories
153370
153552
  - inspecting multiple MCP tools or resources
153371
153553
 
153372
- 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.
153373
153555
 
153374
- 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.` : `
153375
-
153376
- 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.
153377
153557
 
153378
153558
  ### Command execution
153379
153559
 
@@ -153391,7 +153571,7 @@ When embedding images (e.g. uploaded screenshots) in comments or PR bodies, alwa
153391
153571
 
153392
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.
153393
153573
 
153394
- 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.
153395
153575
 
153396
153576
  ### If you get stuck
153397
153577
 
@@ -153430,8 +153610,8 @@ function renderLearningsToc(headings) {
153430
153610
  }
153431
153611
  function buildLearningsSection(ctx) {
153432
153612
  if (!ctx.filePath) return "";
153433
- 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).`;
153434
- 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.
153435
153615
 
153436
153616
  ${renderLearningsToc(ctx.headings)}`;
153437
153617
  return `************* LEARNINGS *************
@@ -153501,18 +153681,10 @@ function resolveInstructions(ctx) {
153501
153681
 
153502
153682
  // utils/learnings.ts
153503
153683
  import { mkdir, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
153504
- import { dirname as dirname4, join as join14 } from "node:path";
153505
- var LEARNINGS_FILE_NAME = "pullfrog-learnings.md";
153684
+ import { dirname as dirname4, join as join15 } from "node:path";
153685
+
153686
+ // utils/learningsTruncate.ts
153506
153687
  var MAX_LEARNINGS_LENGTH = 1e5;
153507
- function learningsFilePath(tmpdir3) {
153508
- return join14(tmpdir3, LEARNINGS_FILE_NAME);
153509
- }
153510
- async function seedLearningsFile(params) {
153511
- const path3 = learningsFilePath(params.tmpdir);
153512
- await mkdir(dirname4(path3), { recursive: true });
153513
- await writeFile2(path3, params.current ?? "", "utf8");
153514
- return path3;
153515
- }
153516
153688
  var TRUNCATION_LINE_BOUNDARY_TOLERANCE = 4096;
153517
153689
  function truncateAtLineBoundary(body, cap) {
153518
153690
  if (body.length <= cap) return body;
@@ -153522,6 +153694,18 @@ function truncateAtLineBoundary(body, cap) {
153522
153694
  if (cap - lastNewline > TRUNCATION_LINE_BOUNDARY_TOLERANCE) return head;
153523
153695
  return head.slice(0, lastNewline);
153524
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
+ }
153525
153709
  async function readLearningsFile(path3) {
153526
153710
  let raw2;
153527
153711
  try {
@@ -153531,6 +153715,45 @@ async function readLearningsFile(path3) {
153531
153715
  }
153532
153716
  return truncateAtLineBoundary(raw2.trim(), MAX_LEARNINGS_LENGTH);
153533
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
+ }
153534
153757
 
153535
153758
  // utils/normalizeEnv.ts
153536
153759
  var core4 = __toESM(require_core(), 1);
@@ -153590,8 +153813,63 @@ function normalizeEnv() {
153590
153813
  }
153591
153814
  }
153592
153815
 
153593
- // utils/payload.ts
153816
+ // utils/overrides.ts
153594
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);
153595
153873
  import { isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
153596
153874
 
153597
153875
  // utils/versioning.ts
@@ -153655,7 +153933,7 @@ function resolveCwd(cwd) {
153655
153933
  return workspace ? resolve2(workspace, cwd) : cwd;
153656
153934
  }
153657
153935
  function resolvePromptInput() {
153658
- const prompt = core5.getInput("prompt", { required: true });
153936
+ const prompt = core6.getInput("prompt", { required: true });
153659
153937
  let parsed2;
153660
153938
  try {
153661
153939
  parsed2 = JSON.parse(prompt);
@@ -153671,11 +153949,11 @@ function resolvePromptInput() {
153671
153949
  }
153672
153950
  function resolveNonPromptInputs() {
153673
153951
  return Inputs.omit("prompt").assert({
153674
- model: core5.getInput("model") || void 0,
153675
- timeout: core5.getInput("timeout") || void 0,
153676
- cwd: core5.getInput("cwd") || void 0,
153677
- push: core5.getInput("push") || void 0,
153678
- 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
153679
153957
  });
153680
153958
  }
153681
153959
  var isPullfrog = (actor) => {
@@ -153721,10 +153999,386 @@ function resolvePayload(resolvedPromptInput, repoSettings) {
153721
153999
  proxyModel: void 0
153722
154000
  };
153723
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
+ }
153724
154378
 
153725
154379
  // utils/prSummary.ts
153726
154380
  import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
153727
- import { dirname as dirname5, join as join15 } from "node:path";
154381
+ import { dirname as dirname5, join as join16 } from "node:path";
153728
154382
  var SUMMARY_FILE_NAME = "pullfrog-summary.md";
153729
154383
  var SUMMARY_SCAFFOLD = `# PR summary
153730
154384
 
@@ -153734,7 +154388,7 @@ var SUMMARY_SCAFFOLD = `# PR summary
153734
154388
  var MIN_SNAPSHOT_LENGTH = 60;
153735
154389
  var MAX_SNAPSHOT_LENGTH = 32768;
153736
154390
  function summaryFilePath(tmpdir3) {
153737
- return join15(tmpdir3, SUMMARY_FILE_NAME);
154391
+ return join16(tmpdir3, SUMMARY_FILE_NAME);
153738
154392
  }
153739
154393
  async function seedSummaryFile(params) {
153740
154394
  const path3 = summaryFilePath(params.tmpdir);
@@ -153755,76 +154409,43 @@ async function readSummaryFile(path3) {
153755
154409
  if (trimmed.length > MAX_SNAPSHOT_LENGTH) return trimmed.slice(0, MAX_SNAPSHOT_LENGTH);
153756
154410
  return trimmed;
153757
154411
  }
153758
-
153759
- // utils/reviewCleanup.ts
153760
- var RE_REVIEW_PREAMBLE = "Incrementally re-review the new commits on this pull request. Use the IncrementalReview mode.";
153761
- async function postReviewCleanup(ctx) {
153762
- const review = ctx.toolState.review;
153763
- if (!review) return;
153764
- delete ctx.toolState.review;
153765
- await bestEffort(() => reportReviewNodeId(ctx, { nodeId: review.nodeId }), "reportReviewNodeId");
153766
- if (review.reviewedSha) {
153767
- await bestEffort(
153768
- () => dispatchFollowUpReReview(ctx, review.reviewedSha),
153769
- "follow-up re-review dispatch"
153770
- );
153771
- }
153772
- }
153773
- async function bestEffort(fn2, label) {
154412
+ async function fetchPreviousSnapshot(ctx, prNumber) {
154413
+ if (!ctx.githubInstallationToken) return null;
153774
154414
  try {
153775
- await fn2();
153776
- } catch (error49) {
153777
- 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;
153778
154426
  }
153779
154427
  }
153780
- async function dispatchFollowUpReReview(ctx, reviewedSha) {
153781
- const issueNumber = ctx.payload.event.issue_number;
153782
- if (!issueNumber) return;
153783
- const pr = await ctx.octokit.rest.pulls.get({
153784
- owner: ctx.repo.owner,
153785
- repo: ctx.repo.name,
153786
- pull_number: issueNumber
153787
- });
153788
- if (pr.data.head.sha === reviewedSha) return;
153789
- if (pr.data.state !== "open") return;
153790
- if (pr.data.draft) return;
153791
- log.info(
153792
- `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`
153793
- );
153794
- const event = {
153795
- trigger: "pull_request_synchronize",
153796
- issue_number: issueNumber,
153797
- is_pr: true,
153798
- title: pr.data.title,
153799
- body: null,
153800
- branch: pr.data.head.ref,
153801
- before_sha: reviewedSha,
153802
- silent: true
153803
- };
153804
- if (ctx.payload.event.authorPermission) {
153805
- 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;
153806
154437
  }
153807
- const payload = {
153808
- "~pullfrog": true,
153809
- version: ctx.payload.version,
153810
- model: ctx.payload.model,
153811
- prompt: "",
153812
- eventInstructions: RE_REVIEW_PREAMBLE,
153813
- event
153814
- };
153815
- await ctx.octokit.rest.actions.createWorkflowDispatch({
153816
- owner: ctx.repo.owner,
153817
- repo: ctx.repo.name,
153818
- workflow_id: getCurrentWorkflowFilename(),
153819
- ref: pr.data.base.repo.default_branch,
153820
- 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)}`);
153821
154447
  });
153822
154448
  }
153823
- function getCurrentWorkflowFilename() {
153824
- const ref = process.env.GITHUB_WORKFLOW_REF ?? "";
153825
- const match3 = ref.match(/\/([^/]+)@/);
153826
- return match3?.[1] ?? "pullfrog.yml";
153827
- }
153828
154449
 
153829
154450
  // utils/run.ts
153830
154451
  async function handleAgentResult(ctx) {
@@ -153860,10 +154481,10 @@ async function handleAgentResult(ctx) {
153860
154481
  };
153861
154482
  }
153862
154483
 
154484
+ // utils/runContextData.ts
154485
+ var core9 = __toESM(require_core(), 1);
154486
+
153863
154487
  // utils/runContext.ts
153864
- function isInfraCovered(params) {
153865
- return params.isOss || params.plan === "payg";
153866
- }
153867
154488
  var defaultSettings = {
153868
154489
  model: null,
153869
154490
  modes: [],
@@ -153933,13 +154554,12 @@ async function fetchRunContext(params) {
153933
154554
  }
153934
154555
 
153935
154556
  // utils/runContextData.ts
153936
- var core6 = __toESM(require_core(), 1);
153937
154557
  async function resolveRunContextData(params) {
153938
154558
  log.info(`\xBB running Pullfrog v${package_default.version}...`);
153939
154559
  const repoContext = parseRepoContext();
153940
154560
  let oidcToken;
153941
154561
  try {
153942
- oidcToken = await core6.getIDToken("pullfrog-api");
154562
+ oidcToken = await core9.getIDToken("pullfrog-api");
153943
154563
  } catch {
153944
154564
  }
153945
154565
  const [repoResponse, runContext] = await Promise.all([
@@ -153961,13 +154581,240 @@ async function resolveRunContextData(params) {
153961
154581
  };
153962
154582
  }
153963
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
+
153964
154811
  // utils/setup.ts
153965
154812
  import { execFileSync as execFileSync5, execSync as execSync3 } from "node:child_process";
153966
154813
  import { mkdtempSync } from "node:fs";
153967
154814
  import { tmpdir as tmpdir2 } from "node:os";
153968
- import { join as join16 } from "node:path";
154815
+ import { join as join17 } from "node:path";
153969
154816
  function createTempDirectory() {
153970
- const sharedTempDir = mkdtempSync(join16(tmpdir2(), "pullfrog-"));
154817
+ const sharedTempDir = mkdtempSync(join17(tmpdir2(), "pullfrog-"));
153971
154818
  process.env.PULLFROG_TEMP_DIR = sharedTempDir;
153972
154819
  log.info(`\xBB created temp dir at ${sharedTempDir}`);
153973
154820
  return sharedTempDir;
@@ -154071,25 +154918,6 @@ async function setupGit(params) {
154071
154918
  log.info("\xBB git authentication configured");
154072
154919
  }
154073
154920
 
154074
- // utils/time.ts
154075
- var TIMEOUT_DISABLED = "none";
154076
- var TIME_STRING_REGEX = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/;
154077
- function parseTimeString(input) {
154078
- const match3 = input.match(TIME_STRING_REGEX);
154079
- if (!match3 || !match3[1] && !match3[2] && !match3[3]) return null;
154080
- const hours = parseInt(match3[1] || "0", 10);
154081
- const minutes = parseInt(match3[2] || "0", 10);
154082
- const seconds = parseInt(match3[3] || "0", 10);
154083
- return (hours * 3600 + minutes * 60 + seconds) * 1e3;
154084
- }
154085
- var TIMEOUT_MAX_MS = 2147483647;
154086
- function resolveTimeoutMs(input) {
154087
- if (!input) return null;
154088
- const parsed2 = parseTimeString(input);
154089
- if (parsed2 === null || parsed2 <= 0 || parsed2 > TIMEOUT_MAX_MS) return null;
154090
- return parsed2;
154091
- }
154092
-
154093
154921
  // utils/todoTracking.ts
154094
154922
  function isValidTodoStatus(value2) {
154095
154923
  return value2 === "pending" || value2 === "in_progress" || value2 === "completed" || value2 === "cancelled";
@@ -154226,305 +155054,42 @@ async function resolveRun(params) {
154226
155054
  let jobId;
154227
155055
  const jobName = process.env.GITHUB_JOB;
154228
155056
  if (jobName && runId) {
154229
- const jobs = await params.octokit.rest.actions.listJobsForWorkflowRun({
154230
- owner,
154231
- repo,
154232
- run_id: runId
154233
- });
154234
- const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
154235
- if (matchingJob) {
154236
- jobId = String(matchingJob.id);
154237
- 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}`);
154238
155071
  }
154239
155072
  }
154240
155073
  return { runId, jobId };
154241
155074
  }
154242
155075
 
154243
155076
  // main.ts
154244
- function resolveOutputSchema() {
154245
- const raw2 = core7.getInput("output_schema");
154246
- if (!raw2) return void 0;
154247
- let parsed2;
154248
- try {
154249
- parsed2 = JSON.parse(raw2);
154250
- } catch {
154251
- throw new Error(`invalid output_schema: not valid JSON`);
154252
- }
154253
- if (!parsed2 || typeof parsed2 !== "object" || Array.isArray(parsed2)) {
154254
- throw new Error(`invalid output_schema: must be a JSON object`);
154255
- }
154256
- log.info("\xBB structured output schema provided \u2014 output will be required");
154257
- return parsed2;
154258
- }
154259
- function resolveTimeoutForLog(timeout) {
154260
- if (!timeout) return "1h (default)";
154261
- if (timeout === TIMEOUT_DISABLED) return "none (disabled)";
154262
- return timeout;
154263
- }
154264
- function resolveModelForLog(ctx) {
154265
- const envModel = process.env.PULLFROG_MODEL?.trim();
154266
- if (envModel) return `${envModel} (override via PULLFROG_MODEL)`;
154267
- if (ctx.payload.proxyModel) return `${ctx.payload.proxyModel} (proxy)`;
154268
- if (ctx.resolvedModel && ctx.payload.model && ctx.payload.model !== ctx.resolvedModel) {
154269
- return `${ctx.resolvedModel} (resolved from ${ctx.payload.model})`;
154270
- }
154271
- if (ctx.resolvedModel) return ctx.resolvedModel;
154272
- if (ctx.payload.model) return `${ctx.payload.model} (unresolved)`;
154273
- return "auto";
154274
- }
154275
- function resolveAgentForLog(ctx) {
154276
- const envAgent = process.env.PULLFROG_AGENT?.trim();
154277
- if (envAgent && envAgent === ctx.agentName) {
154278
- return `${ctx.agentName} (override via PULLFROG_AGENT)`;
154279
- }
154280
- if (ctx.agentName === "claude" && ctx.resolvedModel) {
154281
- return `${ctx.agentName} (auto-selected for ${ctx.resolvedModel})`;
154282
- }
154283
- return ctx.agentName;
154284
- }
154285
- var BillingError = class extends Error {
154286
- code;
154287
- declineCode;
154288
- needsReauthentication;
154289
- constructor(message, opts = {}) {
154290
- super(message);
154291
- this.name = "BillingError";
154292
- this.code = opts.code ?? null;
154293
- this.declineCode = opts.declineCode ?? null;
154294
- this.needsReauthentication = opts.needsReauthentication ?? false;
154295
- }
154296
- };
154297
- var TransientError = class extends Error {
154298
- constructor(message) {
154299
- super(message);
154300
- this.name = "TransientError";
154301
- }
154302
- };
154303
- function billingConsoleUrl(owner, anchor) {
154304
- return `https://pullfrog.com/console/${encodeURIComponent(owner)}#${anchor}`;
154305
- }
154306
- function formatBillingErrorSummary(error49, owner) {
154307
- if (error49.code === "router_requires_card") {
154308
- return [
154309
- "**Add a card to start using Pullfrog Router.**",
154310
- "",
154311
- "Router proxies OpenRouter at raw cost \u2014 no platform markup. Add a card and we'll auto-reload your wallet so runs keep flowing.",
154312
- "",
154313
- `[Add a card \u2192](${billingConsoleUrl(owner, "model-access")})`
154314
- ].join("\n");
154315
- }
154316
- if (error49.code === "router_balance_exhausted") {
154317
- return [
154318
- "**Your Pullfrog Router balance is exhausted.**",
154319
- "",
154320
- "You have a card on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.",
154321
- "",
154322
- `[Top up balance \u2192](${billingConsoleUrl(owner, "billing")}) \xB7 [Enable auto-reload \u2192](${billingConsoleUrl(owner, "model-access")})`
154323
- ].join("\n");
154324
- }
154325
- if (error49.code === "router_keylimit_exhausted") {
154326
- return [
154327
- "**This run was cut short \u2014 your Pullfrog Router balance ran out mid-run.**",
154328
- "",
154329
- "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.",
154330
- "",
154331
- `[Top up balance \u2192](${billingConsoleUrl(owner, "billing")}) \xB7 [Enable auto-reload \u2192](${billingConsoleUrl(owner, "model-access")})`
154332
- ].join("\n");
154333
- }
154334
- if (error49.needsReauthentication) {
154335
- const code = error49.declineCode ?? "authentication_required";
154336
- return [
154337
- `**Your card issuer requires 3D Secure on every charge** (\`${code}\`).`,
154338
- "",
154339
- "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.",
154340
- "",
154341
- `[Top up balance \u2192](${billingConsoleUrl(owner, "billing")})`
154342
- ].join("\n");
154343
- }
154344
- if (error49.declineCode) {
154345
- return [
154346
- `**Your card was declined** (\`${error49.declineCode}\`).`,
154347
- "",
154348
- "Update your payment method and Pullfrog will retry on the next run.",
154349
- "",
154350
- `[Update payment method \u2192](${billingConsoleUrl(owner, "billing")})`
154351
- ].join("\n");
154352
- }
154353
- return [
154354
- "**Your Pullfrog balance is empty.**",
154355
- "",
154356
- "Top up your balance or enable auto-reload to keep runs flowing.",
154357
- "",
154358
- `[Manage billing \u2192](${billingConsoleUrl(owner, "billing")})`
154359
- ].join("\n");
154360
- }
154361
- function formatTransientErrorSummary(error49, owner) {
154362
- return [
154363
- "**Pullfrog billing is temporarily unavailable.**",
154364
- "",
154365
- error49.message,
154366
- "",
154367
- `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")}).`
154368
- ].join("\n");
154369
- }
154370
- async function mintProxyKey(ctx) {
154371
- try {
154372
- const headers = await buildProxyTokenHeaders(ctx);
154373
- if (!headers) return null;
154374
- const response = await apiFetch({
154375
- path: "/api/proxy-token",
154376
- method: "POST",
154377
- headers
154378
- });
154379
- if (response.status === 402) {
154380
- const body = await response.json().catch(() => null);
154381
- throw new BillingError(body?.error ?? "insufficient balance", {
154382
- code: body?.code ?? null,
154383
- declineCode: body?.declineCode ?? null,
154384
- needsReauthentication: body?.needsReauthentication ?? false
154385
- });
154386
- }
154387
- if (response.status === 503) {
154388
- const body = await response.json().catch(() => null);
154389
- throw new TransientError(
154390
- body?.error ?? "billing service temporarily unavailable \u2014 retry shortly"
154391
- );
154392
- }
154393
- if (!response.ok) {
154394
- log.warning(`proxy key mint failed (${response.status})`);
154395
- return null;
154396
- }
154397
- const data = await response.json();
154398
- return data.key;
154399
- } catch (error49) {
154400
- if (error49 instanceof BillingError) throw error49;
154401
- if (error49 instanceof TransientError) throw error49;
154402
- log.warning(`proxy key mint error: ${error49 instanceof Error ? error49.message : String(error49)}`);
154403
- return null;
154404
- } finally {
154405
- delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
154406
- delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
154407
- }
154408
- }
154409
- async function buildProxyTokenHeaders(ctx) {
154410
- if (ctx.oidcCredentials) {
154411
- process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
154412
- process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
154413
- const oidcToken = await core7.getIDToken("pullfrog-api");
154414
- delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
154415
- delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
154416
- return { Authorization: `Bearer ${oidcToken}` };
154417
- }
154418
- if (isLocalApiUrl()) {
154419
- log.info(`\xBB proxy: dev bypass (x-dev-repo) for ${ctx.repo.owner}/${ctx.repo.name}`);
154420
- return { "x-dev-repo": `${ctx.repo.owner}/${ctx.repo.name}` };
154421
- }
154422
- return null;
154423
- }
154424
- async function resolveProxyModel(ctx) {
154425
- if (process.env.PULLFROG_MODEL?.trim()) return;
154426
- const needsProxy = isInfraCovered({ isOss: ctx.oss, plan: ctx.plan }) && ctx.proxyModel;
154427
- if (!needsProxy) return;
154428
- if (!ctx.oidcCredentials && !isLocalApiUrl()) {
154429
- log.warning("\xBB proxy requested but no OIDC credentials available \u2014 skipping");
154430
- return;
154431
- }
154432
- const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials, repo: ctx.repo });
154433
- if (!key) return;
154434
- process.env.OPENROUTER_API_KEY = key;
154435
- core7.setSecret(key);
154436
- ctx.payload.proxyModel = ctx.proxyModel;
154437
- const label = ctx.oss ? "oss" : "router";
154438
- log.info(`\xBB proxy: ${label} \u2192 ${ctx.proxyModel}`);
154439
- }
154440
- async function fetchPreviousSnapshot(ctx, prNumber) {
154441
- if (!ctx.githubInstallationToken) return null;
154442
- try {
154443
- const response = await apiFetch({
154444
- path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`,
154445
- method: "GET",
154446
- headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
154447
- signal: AbortSignal.timeout(1e4)
154448
- });
154449
- if (!response.ok) return null;
154450
- const data = await response.json();
154451
- return typeof data.snapshot === "string" && data.snapshot.length > 0 ? data.snapshot : null;
154452
- } catch {
154453
- return null;
154454
- }
154455
- }
154456
- async function persistLearnings(ctx) {
154457
- const filePath = ctx.toolState.learningsFilePath;
154458
- if (!filePath) return;
154459
- if (ctx.toolState.learningsPersistAttempted) return;
154460
- ctx.toolState.learningsPersistAttempted = true;
154461
- const current = await readLearningsFile(filePath);
154462
- if (current === null) {
154463
- log.debug(`learnings tmpfile missing or unreadable at ${filePath} \u2014 skipping persist`);
154464
- return;
154465
- }
154466
- const seed = ctx.toolState.learningsSeed?.trim() ?? "";
154467
- if (current === seed) {
154468
- log.debug("learnings tmpfile unchanged from seed \u2014 skipping persist");
154469
- return;
154470
- }
154471
- try {
154472
- const response = await apiFetch({
154473
- path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/learnings`,
154474
- method: "PATCH",
154475
- headers: {
154476
- authorization: `Bearer ${ctx.apiToken}`,
154477
- "content-type": "application/json"
154478
- },
154479
- body: JSON.stringify({
154480
- learnings: current,
154481
- model: ctx.toolState.model
154482
- }),
154483
- signal: AbortSignal.timeout(1e4)
154484
- });
154485
- if (!response.ok) {
154486
- const error49 = await response.text().catch(() => "(no body)");
154487
- log.warning(`learnings persist failed (${response.status}): ${error49}`);
154488
- return;
154489
- }
154490
- log.info("\xBB learnings updated");
154491
- } catch (err) {
154492
- log.warning(`learnings persist failed: ${err instanceof Error ? err.message : String(err)}`);
154493
- }
154494
- }
154495
- async function persistSummary(ctx) {
154496
- const filePath = ctx.toolState.summaryFilePath;
154497
- if (!filePath) return;
154498
- if (ctx.toolState.summaryPersistAttempted) return;
154499
- ctx.toolState.summaryPersistAttempted = true;
154500
- const snapshot2 = await readSummaryFile(filePath);
154501
- if (!snapshot2) {
154502
- log.debug(`pr summary tmpfile missing or invalid at ${filePath} \u2014 skipping persist`);
154503
- return;
154504
- }
154505
- const seed = ctx.toolState.summarySeed?.trim();
154506
- if (seed !== void 0 && snapshot2 === seed) {
154507
- log.warning(
154508
- "\xBB pr summary tmpfile unchanged from seed \u2014 skipping persist (agent did not edit it)"
154509
- );
154510
- return;
154511
- }
154512
- await patchWorkflowRunFields(ctx, { summarySnapshot: snapshot2 }).catch((err) => {
154513
- log.debug(`pr summary persist failed: ${err instanceof Error ? err.message : String(err)}`);
154514
- });
154515
- }
154516
- async function writeJobSummary(toolState, finalOutput) {
154517
- const usageSummary = formatUsageSummary(toolState.usageEntries);
154518
- const body = toolState.lastProgressBody || finalOutput;
154519
- const summaryParts = [body, usageSummary].filter(Boolean);
154520
- if (summaryParts.length > 0) {
154521
- await writeSummary(summaryParts.join("\n\n"));
154522
- }
154523
- }
154524
155077
  async function main() {
154525
155078
  var _stack2 = [];
154526
155079
  try {
154527
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
+ }
154528
155093
  const usageSummaryPath = process.env.PULLFROG_USAGE_SUMMARY_PATH;
154529
155094
  if (usageSummaryPath) {
154530
155095
  onExitSignal(() => writeGitHubUsageSummaryToFile(usageSummaryPath));
@@ -154568,34 +155133,14 @@ async function main() {
154568
155133
  delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
154569
155134
  delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
154570
155135
  }
154571
- try {
154572
- await resolveProxyModel({
154573
- payload,
154574
- oss: runContext.oss,
154575
- plan: runContext.plan,
154576
- proxyModel: runContext.proxyModel,
154577
- oidcCredentials,
154578
- repo: runContext.repo
154579
- });
154580
- } catch (error49) {
154581
- if (error49 instanceof BillingError) {
154582
- const summary2 = formatBillingErrorSummary(error49, runContext.repo.owner);
154583
- await writeSummary(summary2).catch(() => {
154584
- });
154585
- await reportErrorToComment({ toolState, error: summary2 }).catch(() => {
154586
- });
154587
- throw error49;
154588
- }
154589
- if (error49 instanceof TransientError) {
154590
- const summary2 = formatTransientErrorSummary(error49, runContext.repo.owner);
154591
- await writeSummary(summary2).catch(() => {
154592
- });
154593
- await reportErrorToComment({ toolState, error: summary2 }).catch(() => {
154594
- });
154595
- throw error49;
154596
- }
154597
- throw error49;
154598
- }
155136
+ await runProxyResolution({
155137
+ payload,
155138
+ oss: runContext.oss,
155139
+ proxyModel: runContext.proxyModel,
155140
+ oidcCredentials,
155141
+ repo: runContext.repo,
155142
+ toolState
155143
+ });
154599
155144
  const octokit = createOctokit(tokenRef.mcpToken);
154600
155145
  const runInfo = await resolveRun({ octokit });
154601
155146
  let toolContext;
@@ -154622,12 +155167,24 @@ async function main() {
154622
155167
  const tmpdir3 = createTempDirectory();
154623
155168
  const gitAuthServer = __using(_stack, await startGitAuthServer(tmpdir3), true);
154624
155169
  setGitAuthServer(gitAuthServer);
154625
- 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
+ }
154626
155183
  const agent2 = resolveAgent({ model: resolvedModel });
154627
- toolState.model = payload.proxyModel ?? resolvedModel ?? payload.model;
155184
+ toolState.model = payload.proxyModel ?? resolvedModel ?? effectiveSlug;
154628
155185
  validateAgentApiKey({
154629
155186
  agent: agent2,
154630
- model: payload.proxyModel ?? resolvedModel ?? payload.model,
155187
+ model: payload.proxyModel ?? resolvedModel ?? effectiveSlug,
154631
155188
  owner: runContext.repo.owner,
154632
155189
  name: runContext.repo.name
154633
155190
  });
@@ -154710,14 +155267,7 @@ async function main() {
154710
155267
  onExitSignal(() => persistSummary(ctxForExit));
154711
155268
  }
154712
155269
  startInstallation(toolContext);
154713
- const modelForLog = resolveModelForLog({ payload, resolvedModel });
154714
- const agentForLog = resolveAgentForLog({ agentName: agent2.name, resolvedModel });
154715
- const timeoutForLog = resolveTimeoutForLog(payload.timeout);
154716
- log.info(`\xBB model: ${modelForLog}`);
154717
- log.info(`\xBB agent: ${agentForLog}`);
154718
- log.info(`\xBB push: ${payload.push}`);
154719
- log.info(`\xBB shell: ${payload.shell}`);
154720
- log.info(`\xBB timeout: ${timeoutForLog}`);
155270
+ logRunStartup({ payload, resolvedModel, agentName: agent2.name });
154721
155271
  const instructions = resolveInstructions({
154722
155272
  payload,
154723
155273
  repo: runContext.repo,
@@ -154741,7 +155291,7 @@ ${instructions.user}` : null,
154741
155291
  log.info(instructions.full);
154742
155292
  });
154743
155293
  if (agentId === "opencode") {
154744
- const pluginDir = join17(process.cwd(), ".opencode", "plugin");
155294
+ const pluginDir = join18(process.cwd(), ".opencode", "plugin");
154745
155295
  const hasPlugins = existsSync7(pluginDir) && readdirSync(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
154746
155296
  if (hasPlugins && toolState.dependencyInstallation?.promise) {
154747
155297
  log.info(
@@ -154801,6 +155351,7 @@ ${instructions.user}` : null,
154801
155351
  todoTracker,
154802
155352
  stopScript: runContext.repoSettings.stopScript,
154803
155353
  toolState,
155354
+ apiToken: runContext.apiToken,
154804
155355
  onActivityTimeout: onInnerActivityTimeout,
154805
155356
  onToolUse: (event) => {
154806
155357
  const wasTracked = recordDiffReadFromToolUse({
@@ -154850,42 +155401,7 @@ ${instructions.user}` : null,
154850
155401
  "output_schema was provided but agent did not call set_output \u2014 structured output is required"
154851
155402
  );
154852
155403
  }
154853
- if (toolContext) {
154854
- await postReviewCleanup(toolContext).catch((error49) => {
154855
- log.debug(`post-review cleanup failed: ${error49}`);
154856
- });
154857
- }
154858
- if (toolContext) {
154859
- await persistSummary(toolContext);
154860
- }
154861
- if (toolContext) {
154862
- await persistLearnings(toolContext);
154863
- }
154864
- if (!result.success && toolContext && toolState.progressComment) {
154865
- const rawError = result.error || "agent run failed";
154866
- const errorBody = isApiKeyAuthError(rawError) ? formatApiKeyErrorSummary({
154867
- owner: runContext.repo.owner,
154868
- name: runContext.repo.name,
154869
- raw: rawError
154870
- }) : rawError;
154871
- await reportErrorToComment({ toolState, error: errorBody }).catch((error49) => {
154872
- log.debug(`failure error report failed: ${error49}`);
154873
- });
154874
- }
154875
- if (toolContext && result.success && toolState.progressComment && !toolState.finalSummaryWritten) {
154876
- await deleteProgressComment(toolContext).catch((error49) => {
154877
- log.debug(`stranded progress comment cleanup failed: ${error49}`);
154878
- });
154879
- }
154880
- try {
154881
- await writeJobSummary(toolState, result.output);
154882
- } catch (error49) {
154883
- log.debug(`job summary write failed: ${error49}`);
154884
- }
154885
- if (toolState.output) {
154886
- log.info(`::pullfrog-output::${Buffer.from(toolState.output).toString("base64")}`);
154887
- core7.setOutput("result", toolState.output);
154888
- }
155404
+ await finalizeSuccessRun({ toolContext, toolState, result, repo: runContext.repo });
154889
155405
  return await handleAgentResult({
154890
155406
  result,
154891
155407
  toolState,
@@ -154903,43 +155419,14 @@ ${instructions.user}` : null,
154903
155419
  todoTracker?.cancel();
154904
155420
  killTrackedChildren();
154905
155421
  log.error(errorMessage);
154906
- const billingError = isRouterKeylimitExhaustedError(errorMessage) ? new BillingError(errorMessage, { code: "router_keylimit_exhausted" }) : null;
154907
- const isHang = errorMessage.startsWith("activity timeout") || errorMessage.startsWith("agent still pending");
154908
- const hangBody = isHang ? formatAgentHangBody({ diagnostic: toolState.agentDiagnostic, isHang: true, errorMessage }) : null;
154909
- const apiKeySource = hangBody ?? errorMessage;
154910
- const apiKeyErrorSummary = !billingError && isApiKeyAuthError(apiKeySource) ? formatApiKeyErrorSummary({
154911
- owner: runContext.repo.owner,
154912
- name: runContext.repo.name,
154913
- raw: apiKeySource
154914
- }) : null;
154915
- try {
154916
- const errorSummary = billingError ? formatBillingErrorSummary(billingError, runContext.repo.owner) : apiKeyErrorSummary ?? (hangBody ? `### \u274C Pullfrog failed
154917
-
154918
- ${hangBody}` : `### \u274C Pullfrog failed
154919
-
154920
- \`\`\`
154921
- ${errorMessage}
154922
- \`\`\``);
154923
- const usageSummary = formatUsageSummary(toolState.usageEntries);
154924
- const parts = [errorSummary, toolState.lastProgressBody, usageSummary].filter(Boolean);
154925
- await writeSummary(parts.join("\n\n"));
154926
- } catch {
154927
- }
154928
- try {
154929
- const commentBody = billingError ? formatBillingErrorSummary(billingError, runContext.repo.owner) : apiKeyErrorSummary ?? hangBody ?? errorMessage;
154930
- await reportErrorToComment({ toolState, error: commentBody });
154931
- } catch {
154932
- }
154933
- if (toolContext) {
154934
- await postReviewCleanup(toolContext).catch((error50) => {
154935
- log.debug(`post-review cleanup failed: ${error50}`);
154936
- });
154937
- }
154938
- if (toolContext) {
154939
- await persistSummary(toolContext);
154940
- }
155422
+ const rendered = renderRunError({
155423
+ errorMessage,
155424
+ repo: runContext.repo,
155425
+ agentDiagnostic: toolState.agentDiagnostic
155426
+ });
155427
+ await writeRunErrorOutputs({ rendered, toolState });
154941
155428
  if (toolContext) {
154942
- await persistLearnings(toolContext);
155429
+ await persistRunArtifacts(toolContext);
154943
155430
  }
154944
155431
  return {
154945
155432
  success: false,