pullfrog 0.0.205 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -108399,7 +108399,11 @@ function resolveCliModel(slug2) {
108399
108399
  var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
108400
108400
  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>`;
108401
108401
  function formatModelLabel(slug2) {
108402
- const alias = resolveDisplayAlias(slug2);
108402
+ const alias = resolveDisplayAlias(slug2) ?? // reverse-lookup: when the caller passes an effective model (proxy or
108403
+ // resolved target like "openrouter/anthropic/claude-opus-4.7") instead of
108404
+ // a stored alias slug, find the alias whose resolve target matches so we
108405
+ // still render a friendly display name.
108406
+ modelAliases.find((a) => a.resolve === slug2 || a.openRouterResolve === slug2);
108403
108407
  if (!alias) return `\`${slug2}\``;
108404
108408
  return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
108405
108409
  }
@@ -108474,10 +108478,13 @@ var defaultShouldRetry = (error49) => {
108474
108478
  return error49.name === "AbortError" || error49.message.includes("fetch failed") || error49.message.includes("ECONNRESET") || error49.message.includes("ETIMEDOUT");
108475
108479
  };
108476
108480
  async function retry(fn2, options = {}) {
108477
- const maxAttempts = options.maxAttempts ?? 3;
108478
- const delayMs = options.delayMs ?? 1e3;
108479
108481
  const shouldRetry = options.shouldRetry ?? defaultShouldRetry;
108480
108482
  const label = options.label ?? "operation";
108483
+ const delays = options.delaysMs ? Array.from(options.delaysMs) : Array.from(
108484
+ { length: (options.maxAttempts ?? 3) - 1 },
108485
+ (_2, i) => (options.delayMs ?? 1e3) * (i + 1)
108486
+ );
108487
+ const maxAttempts = delays.length + 1;
108481
108488
  let lastError;
108482
108489
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
108483
108490
  try {
@@ -108487,7 +108494,7 @@ async function retry(fn2, options = {}) {
108487
108494
  if (attempt === maxAttempts || !shouldRetry(error49)) {
108488
108495
  throw error49;
108489
108496
  }
108490
- const delay2 = delayMs * attempt;
108497
+ const delay2 = delays[attempt - 1];
108491
108498
  log.info(`\xBB ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay2}ms...`);
108492
108499
  await sleep(delay2);
108493
108500
  }
@@ -108501,7 +108508,6 @@ var STRING_KEYS = [
108501
108508
  "issueNodeId",
108502
108509
  "reviewNodeId",
108503
108510
  "planCommentNodeId",
108504
- "summaryCommentNodeId",
108505
108511
  "summarySnapshot"
108506
108512
  ];
108507
108513
  var NUMBER_KEYS = [
@@ -109973,6 +109979,7 @@ async function spawn(options) {
109973
109979
  const startTime = performance3.now();
109974
109980
  let stdoutBuffer = "";
109975
109981
  let stderrBuffer = "";
109982
+ const killGroup = options.killGroup ?? false;
109976
109983
  return new Promise((resolve3, reject) => {
109977
109984
  const child = nodeSpawn(options.cmd, options.args, {
109978
109985
  env: options.env || {
@@ -109980,9 +109987,20 @@ async function spawn(options) {
109980
109987
  HOME: process.env.HOME || ""
109981
109988
  },
109982
109989
  stdio: options.stdio || ["pipe", "pipe", "pipe"],
109983
- cwd: options.cwd || process.cwd()
109990
+ cwd: options.cwd || process.cwd(),
109991
+ detached: killGroup
109984
109992
  });
109985
- trackChild({ child });
109993
+ const killSelf = (signal) => {
109994
+ if (killGroup && child.pid) {
109995
+ try {
109996
+ process.kill(-child.pid, signal);
109997
+ return;
109998
+ } catch {
109999
+ }
110000
+ }
110001
+ child.kill(signal);
110002
+ };
110003
+ trackChild({ child, killGroup });
109986
110004
  let timeoutId;
109987
110005
  let sigkillEscalatorId;
109988
110006
  let activityCheckIntervalId;
@@ -109993,10 +110011,10 @@ async function spawn(options) {
109993
110011
  if (options.timeout) {
109994
110012
  timeoutId = setTimeout(() => {
109995
110013
  isTimedOut = true;
109996
- child.kill("SIGTERM");
110014
+ killSelf("SIGTERM");
109997
110015
  sigkillEscalatorId = setTimeout(() => {
109998
110016
  if (!child.killed) {
109999
- child.kill("SIGKILL");
110017
+ killSelf("SIGKILL");
110000
110018
  }
110001
110019
  }, 5e3);
110002
110020
  }, options.timeout);
@@ -110006,6 +110024,11 @@ async function spawn(options) {
110006
110024
  `spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms`
110007
110025
  );
110008
110026
  activityCheckIntervalId = setInterval(() => {
110027
+ if (options.isPausedExternally?.()) {
110028
+ lastActivityTime = performance3.now();
110029
+ log.debug(`spawn activity check: pid=${child.pid} paused externally`);
110030
+ return;
110031
+ }
110009
110032
  const idleMs = performance3.now() - lastActivityTime;
110010
110033
  log.debug(
110011
110034
  `spawn activity check: pid=${child.pid} idle=${Math.round(idleMs)}ms / ${activityTimeoutMs}ms`
@@ -110015,9 +110038,9 @@ async function spawn(options) {
110015
110038
  killedAtIdleMs = idleMs;
110016
110039
  const idleSec = Math.round(idleMs / 1e3);
110017
110040
  log.info(
110018
- `no output for ${idleSec}s from pid=${child.pid} (${options.cmd}), killing process`
110041
+ `no output for ${idleSec}s from pid=${child.pid} (${options.cmd}), killing process${killGroup ? " group" : ""}`
110019
110042
  );
110020
- child.kill("SIGKILL");
110043
+ killSelf("SIGKILL");
110021
110044
  clearInterval(activityCheckIntervalId);
110022
110045
  try {
110023
110046
  options.onActivityTimeout?.();
@@ -142526,7 +142549,7 @@ var import_semver = __toESM(require_semver2(), 1);
142526
142549
  // package.json
142527
142550
  var package_default = {
142528
142551
  name: "pullfrog",
142529
- version: "0.0.205",
142552
+ version: "0.1.1",
142530
142553
  type: "module",
142531
142554
  bin: {
142532
142555
  pullfrog: "dist/cli.mjs",
@@ -143654,6 +143677,12 @@ function getHttpStatus(err) {
143654
143677
  const status = err.status;
143655
143678
  return typeof status === "number" ? status : void 0;
143656
143679
  }
143680
+ function isTransientReviewError(err) {
143681
+ if (getHttpStatus(err) !== 422) return false;
143682
+ const msg = err instanceof Error ? err.message : String(err);
143683
+ return /internal error occurred, please try again/i.test(msg);
143684
+ }
143685
+ var TRANSIENT_REVIEW_RETRY_DELAYS_MS = [1e3, 3e3];
143657
143686
  function commentableLinesForFile(patch) {
143658
143687
  const right = /* @__PURE__ */ new Set();
143659
143688
  const left = /* @__PURE__ */ new Set();
@@ -143921,12 +143950,26 @@ function CreatePullRequestReviewTool(ctx) {
143921
143950
  }
143922
143951
  let result;
143923
143952
  try {
143924
- result = body ? await createAndSubmitWithFooter(ctx, params, {
143925
- body,
143926
- approved: approved ?? false,
143927
- hasComments: (params.comments?.length ?? 0) > 0
143928
- }) : await createReviewWithStrandedRecovery(ctx, params);
143953
+ result = await retry(
143954
+ () => body ? createAndSubmitWithFooter(ctx, params, {
143955
+ body,
143956
+ approved: approved ?? false,
143957
+ hasComments: (params.comments?.length ?? 0) > 0
143958
+ }) : createReviewWithStrandedRecovery(ctx, params),
143959
+ {
143960
+ delaysMs: TRANSIENT_REVIEW_RETRY_DELAYS_MS,
143961
+ shouldRetry: isTransientReviewError,
143962
+ label: "review submission"
143963
+ }
143964
+ );
143929
143965
  } catch (err) {
143966
+ if (isTransientReviewError(err)) {
143967
+ const rawMsg2 = err instanceof Error ? err.message : String(err);
143968
+ throw new Error(
143969
+ `GitHub returned a transient 422 "internal error" on the reviews endpoint after ${TRANSIENT_REVIEW_RETRY_DELAYS_MS.length + 1} attempts. This is a GitHub-side issue, not a problem with your review content. Do NOT modify or drop inline comments \u2014 their content is not the cause. Wait ~30 seconds and call this tool once more with the SAME arguments. If it still fails, submit a body-only review (move all inline feedback into \`body\` as text) so nothing is lost. GitHub said: ${rawMsg2}`,
143970
+ { cause: err }
143971
+ );
143972
+ }
143930
143973
  if (getHttpStatus(err) !== 422 || !params.comments?.length) throw err;
143931
143974
  const details = params.comments.map((c2) => {
143932
143975
  const line = c2.line ?? 0;
@@ -146899,6 +146942,10 @@ function detectProviderError(text) {
146899
146942
  }
146900
146943
  return null;
146901
146944
  }
146945
+ var ROUTER_KEYLIMIT_EXHAUSTED_PATTERN = /requires more credits.*?fewer max_tokens|requested up to \d+ tokens.*?can only afford/is;
146946
+ function isRouterKeylimitExhaustedError(text) {
146947
+ return ROUTER_KEYLIMIT_EXHAUSTED_PATTERN.test(text);
146948
+ }
146902
146949
 
146903
146950
  // utils/skills.ts
146904
146951
  import { spawnSync as spawnSync5 } from "node:child_process";
@@ -147437,6 +147484,12 @@ async function runClaude(params) {
147437
147484
  activityTimeout: 3e5,
147438
147485
  onActivityTimeout: params.onActivityTimeout,
147439
147486
  stdio: ["ignore", "pipe", "pipe"],
147487
+ // run claude in its own process group so SIGKILL on activity timeout /
147488
+ // outer cancellation reaches any subprocesses it spawns (rg, file
147489
+ // watchers, mcp transports, etc). claude itself is a node bundle so
147490
+ // there's no shim-orphan issue like opencode-ai/bin/opencode, but
147491
+ // detached + killGroup is the right default for any agent runtime.
147492
+ killGroup: true,
147440
147493
  onStdout: async (chunk) => {
147441
147494
  const text = chunk.toString();
147442
147495
  output += text;
@@ -147703,6 +147756,7 @@ async function installOpencodeCli() {
147703
147756
  installDependencies: true
147704
147757
  });
147705
147758
  }
147759
+ var PULLFROG_OPENCODE_OUTPUT_LIMIT = 5e3;
147706
147760
  function buildSecurityConfig(ctx, model) {
147707
147761
  const config3 = {
147708
147762
  permission: {
@@ -147795,6 +147849,9 @@ async function runOpenCode(params) {
147795
147849
  const taskDispatchByCallID = /* @__PURE__ */ new Map();
147796
147850
  const pendingTaskDispatches = [];
147797
147851
  const knownNonTaskCallIDs = /* @__PURE__ */ new Set();
147852
+ function isSubagentInFlight() {
147853
+ return taskDispatchByCallID.size > 0 || pendingTaskDispatches.length > 0;
147854
+ }
147798
147855
  function emitSubagentFinished(dispatch, status, output2, matchKind) {
147799
147856
  const subagentDuration = performance7.now() - dispatch.startedAt;
147800
147857
  const outputStr = typeof output2 === "string" ? output2 : "";
@@ -148047,6 +148104,20 @@ async function runOpenCode(params) {
148047
148104
  activityTimeout: 3e5,
148048
148105
  onActivityTimeout: params.onActivityTimeout,
148049
148106
  stdio: ["ignore", "pipe", "pipe"],
148107
+ // node_modules/opencode-ai/bin/opencode is a Node shim that spawnSyncs
148108
+ // the native opencode-<plat>-<arch> binary with stdio:"inherit". without
148109
+ // a process-group kill, SIGKILL hits only the shim, the native binary
148110
+ // is reparented to PID 1, holds our stdout pipe open, and `child.close`
148111
+ // never fires — producing zombie runs. detached + killGroup nukes the
148112
+ // whole tree.
148113
+ killGroup: true,
148114
+ // suspend the inner activity timer while a `task` subagent is in flight.
148115
+ // opencode's task tool encapsulates subagent execution in-process — the
148116
+ // subagent's internal events don't surface on the parent NDJSON stream,
148117
+ // so without this the 5min timeout would falsely fire mid-subagent.
148118
+ // suspend/resume is preferable to a heartbeat because there's no race
148119
+ // between a periodic tick and a subagent finishing between ticks.
148120
+ isPausedExternally: isSubagentInFlight,
148050
148121
  onStdout: async (chunk) => {
148051
148122
  const text = chunk.toString();
148052
148123
  output += text;
@@ -148218,6 +148289,7 @@ var opencode = agent({
148218
148289
  ...homeEnv,
148219
148290
  OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
148220
148291
  OPENCODE_PERMISSION: permissionOverride,
148292
+ OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: PULLFROG_OPENCODE_OUTPUT_LIMIT.toString(),
148221
148293
  GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY
148222
148294
  };
148223
148295
  const repoDir = process.cwd();
@@ -153635,6 +153707,24 @@ function formatBillingErrorSummary(error49, owner) {
153635
153707
  `[Add a card \u2192](${billingConsoleUrl(owner, "model-access")})`
153636
153708
  ].join("\n");
153637
153709
  }
153710
+ if (error49.code === "router_balance_exhausted") {
153711
+ return [
153712
+ "**Your Pullfrog Router balance is exhausted.**",
153713
+ "",
153714
+ "You have a card on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.",
153715
+ "",
153716
+ `[Top up balance \u2192](${billingConsoleUrl(owner, "billing")}) \xB7 [Enable auto-reload \u2192](${billingConsoleUrl(owner, "model-access")})`
153717
+ ].join("\n");
153718
+ }
153719
+ if (error49.code === "router_keylimit_exhausted") {
153720
+ return [
153721
+ "**This run was cut short \u2014 your Pullfrog Router balance ran out mid-run.**",
153722
+ "",
153723
+ "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.",
153724
+ "",
153725
+ `[Top up balance \u2192](${billingConsoleUrl(owner, "billing")}) \xB7 [Enable auto-reload \u2192](${billingConsoleUrl(owner, "model-access")})`
153726
+ ].join("\n");
153727
+ }
153638
153728
  if (error49.needsReauthentication) {
153639
153729
  const code = error49.declineCode ?? "authentication_required";
153640
153730
  return [
@@ -153875,6 +153965,7 @@ async function main() {
153875
153965
  setGitAuthServer(gitAuthServer);
153876
153966
  const resolvedModel = payload.proxyModel ? void 0 : resolveModel({ slug: payload.model });
153877
153967
  const agent2 = resolveAgent({ model: resolvedModel });
153968
+ toolState.model = payload.proxyModel ?? resolvedModel ?? payload.model;
153878
153969
  validateAgentApiKey({
153879
153970
  agent: agent2,
153880
153971
  model: payload.proxyModel ?? resolvedModel ?? payload.model,
@@ -154118,8 +154209,9 @@ ${instructions.user}` : null,
154118
154209
  todoTracker?.cancel();
154119
154210
  killTrackedChildren();
154120
154211
  log.error(errorMessage);
154212
+ const billingError = isRouterKeylimitExhaustedError(errorMessage) ? new BillingError(errorMessage, { code: "router_keylimit_exhausted" }) : null;
154121
154213
  try {
154122
- const errorSummary = `### \u274C Pullfrog failed
154214
+ const errorSummary = billingError ? formatBillingErrorSummary(billingError, runContext.repo.owner) : `### \u274C Pullfrog failed
154123
154215
 
154124
154216
  \`\`\`
154125
154217
  ${errorMessage}
@@ -154130,7 +154222,8 @@ ${errorMessage}
154130
154222
  } catch {
154131
154223
  }
154132
154224
  try {
154133
- await reportErrorToComment({ toolState, error: errorMessage });
154225
+ const commentBody = billingError ? formatBillingErrorSummary(billingError, runContext.repo.owner) : errorMessage;
154226
+ await reportErrorToComment({ toolState, error: commentBody });
154134
154227
  } catch {
154135
154228
  }
154136
154229
  if (toolContext) {
@@ -155983,7 +156076,7 @@ async function run2() {
155983
156076
  }
155984
156077
 
155985
156078
  // cli.ts
155986
- var VERSION10 = "0.0.205";
156079
+ var VERSION10 = "0.1.1";
155987
156080
  var bin = basename2(process.argv[1] || "");
155988
156081
  var PROG = bin === "pf" || bin === "pullfrog" ? bin : "pullfrog";
155989
156082
  var rawArgs = process.argv.slice(2);
package/dist/index.js CHANGED
@@ -108116,7 +108116,11 @@ function resolveCliModel(slug2) {
108116
108116
  var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
108117
108117
  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>`;
108118
108118
  function formatModelLabel(slug2) {
108119
- const alias = resolveDisplayAlias(slug2);
108119
+ const alias = resolveDisplayAlias(slug2) ?? // reverse-lookup: when the caller passes an effective model (proxy or
108120
+ // resolved target like "openrouter/anthropic/claude-opus-4.7") instead of
108121
+ // a stored alias slug, find the alias whose resolve target matches so we
108122
+ // still render a friendly display name.
108123
+ modelAliases.find((a) => a.resolve === slug2 || a.openRouterResolve === slug2);
108120
108124
  if (!alias) return `\`${slug2}\``;
108121
108125
  return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
108122
108126
  }
@@ -108191,10 +108195,13 @@ var defaultShouldRetry = (error49) => {
108191
108195
  return error49.name === "AbortError" || error49.message.includes("fetch failed") || error49.message.includes("ECONNRESET") || error49.message.includes("ETIMEDOUT");
108192
108196
  };
108193
108197
  async function retry(fn2, options = {}) {
108194
- const maxAttempts = options.maxAttempts ?? 3;
108195
- const delayMs = options.delayMs ?? 1e3;
108196
108198
  const shouldRetry = options.shouldRetry ?? defaultShouldRetry;
108197
108199
  const label = options.label ?? "operation";
108200
+ const delays = options.delaysMs ? Array.from(options.delaysMs) : Array.from(
108201
+ { length: (options.maxAttempts ?? 3) - 1 },
108202
+ (_, i) => (options.delayMs ?? 1e3) * (i + 1)
108203
+ );
108204
+ const maxAttempts = delays.length + 1;
108198
108205
  let lastError;
108199
108206
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
108200
108207
  try {
@@ -108204,7 +108211,7 @@ async function retry(fn2, options = {}) {
108204
108211
  if (attempt === maxAttempts || !shouldRetry(error49)) {
108205
108212
  throw error49;
108206
108213
  }
108207
- const delay2 = delayMs * attempt;
108214
+ const delay2 = delays[attempt - 1];
108208
108215
  log.info(`\xBB ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay2}ms...`);
108209
108216
  await sleep(delay2);
108210
108217
  }
@@ -108218,7 +108225,6 @@ var STRING_KEYS = [
108218
108225
  "issueNodeId",
108219
108226
  "reviewNodeId",
108220
108227
  "planCommentNodeId",
108221
- "summaryCommentNodeId",
108222
108228
  "summarySnapshot"
108223
108229
  ];
108224
108230
  var NUMBER_KEYS = [
@@ -109690,6 +109696,7 @@ async function spawn(options) {
109690
109696
  const startTime = performance3.now();
109691
109697
  let stdoutBuffer = "";
109692
109698
  let stderrBuffer = "";
109699
+ const killGroup = options.killGroup ?? false;
109693
109700
  return new Promise((resolve3, reject) => {
109694
109701
  const child = nodeSpawn(options.cmd, options.args, {
109695
109702
  env: options.env || {
@@ -109697,9 +109704,20 @@ async function spawn(options) {
109697
109704
  HOME: process.env.HOME || ""
109698
109705
  },
109699
109706
  stdio: options.stdio || ["pipe", "pipe", "pipe"],
109700
- cwd: options.cwd || process.cwd()
109707
+ cwd: options.cwd || process.cwd(),
109708
+ detached: killGroup
109701
109709
  });
109702
- trackChild({ child });
109710
+ const killSelf = (signal) => {
109711
+ if (killGroup && child.pid) {
109712
+ try {
109713
+ process.kill(-child.pid, signal);
109714
+ return;
109715
+ } catch {
109716
+ }
109717
+ }
109718
+ child.kill(signal);
109719
+ };
109720
+ trackChild({ child, killGroup });
109703
109721
  let timeoutId;
109704
109722
  let sigkillEscalatorId;
109705
109723
  let activityCheckIntervalId;
@@ -109710,10 +109728,10 @@ async function spawn(options) {
109710
109728
  if (options.timeout) {
109711
109729
  timeoutId = setTimeout(() => {
109712
109730
  isTimedOut = true;
109713
- child.kill("SIGTERM");
109731
+ killSelf("SIGTERM");
109714
109732
  sigkillEscalatorId = setTimeout(() => {
109715
109733
  if (!child.killed) {
109716
- child.kill("SIGKILL");
109734
+ killSelf("SIGKILL");
109717
109735
  }
109718
109736
  }, 5e3);
109719
109737
  }, options.timeout);
@@ -109723,6 +109741,11 @@ async function spawn(options) {
109723
109741
  `spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms`
109724
109742
  );
109725
109743
  activityCheckIntervalId = setInterval(() => {
109744
+ if (options.isPausedExternally?.()) {
109745
+ lastActivityTime = performance3.now();
109746
+ log.debug(`spawn activity check: pid=${child.pid} paused externally`);
109747
+ return;
109748
+ }
109726
109749
  const idleMs = performance3.now() - lastActivityTime;
109727
109750
  log.debug(
109728
109751
  `spawn activity check: pid=${child.pid} idle=${Math.round(idleMs)}ms / ${activityTimeoutMs}ms`
@@ -109732,9 +109755,9 @@ async function spawn(options) {
109732
109755
  killedAtIdleMs = idleMs;
109733
109756
  const idleSec = Math.round(idleMs / 1e3);
109734
109757
  log.info(
109735
- `no output for ${idleSec}s from pid=${child.pid} (${options.cmd}), killing process`
109758
+ `no output for ${idleSec}s from pid=${child.pid} (${options.cmd}), killing process${killGroup ? " group" : ""}`
109736
109759
  );
109737
- child.kill("SIGKILL");
109760
+ killSelf("SIGKILL");
109738
109761
  clearInterval(activityCheckIntervalId);
109739
109762
  try {
109740
109763
  options.onActivityTimeout?.();
@@ -142243,7 +142266,7 @@ var import_semver = __toESM(require_semver2(), 1);
142243
142266
  // package.json
142244
142267
  var package_default = {
142245
142268
  name: "pullfrog",
142246
- version: "0.0.205",
142269
+ version: "0.1.1",
142247
142270
  type: "module",
142248
142271
  bin: {
142249
142272
  pullfrog: "dist/cli.mjs",
@@ -143371,6 +143394,12 @@ function getHttpStatus(err) {
143371
143394
  const status = err.status;
143372
143395
  return typeof status === "number" ? status : void 0;
143373
143396
  }
143397
+ function isTransientReviewError(err) {
143398
+ if (getHttpStatus(err) !== 422) return false;
143399
+ const msg = err instanceof Error ? err.message : String(err);
143400
+ return /internal error occurred, please try again/i.test(msg);
143401
+ }
143402
+ var TRANSIENT_REVIEW_RETRY_DELAYS_MS = [1e3, 3e3];
143374
143403
  function commentableLinesForFile(patch) {
143375
143404
  const right = /* @__PURE__ */ new Set();
143376
143405
  const left = /* @__PURE__ */ new Set();
@@ -143638,12 +143667,26 @@ function CreatePullRequestReviewTool(ctx) {
143638
143667
  }
143639
143668
  let result;
143640
143669
  try {
143641
- result = body ? await createAndSubmitWithFooter(ctx, params, {
143642
- body,
143643
- approved: approved ?? false,
143644
- hasComments: (params.comments?.length ?? 0) > 0
143645
- }) : await createReviewWithStrandedRecovery(ctx, params);
143670
+ result = await retry(
143671
+ () => body ? createAndSubmitWithFooter(ctx, params, {
143672
+ body,
143673
+ approved: approved ?? false,
143674
+ hasComments: (params.comments?.length ?? 0) > 0
143675
+ }) : createReviewWithStrandedRecovery(ctx, params),
143676
+ {
143677
+ delaysMs: TRANSIENT_REVIEW_RETRY_DELAYS_MS,
143678
+ shouldRetry: isTransientReviewError,
143679
+ label: "review submission"
143680
+ }
143681
+ );
143646
143682
  } catch (err) {
143683
+ if (isTransientReviewError(err)) {
143684
+ const rawMsg2 = err instanceof Error ? err.message : String(err);
143685
+ throw new Error(
143686
+ `GitHub returned a transient 422 "internal error" on the reviews endpoint after ${TRANSIENT_REVIEW_RETRY_DELAYS_MS.length + 1} attempts. This is a GitHub-side issue, not a problem with your review content. Do NOT modify or drop inline comments \u2014 their content is not the cause. Wait ~30 seconds and call this tool once more with the SAME arguments. If it still fails, submit a body-only review (move all inline feedback into \`body\` as text) so nothing is lost. GitHub said: ${rawMsg2}`,
143687
+ { cause: err }
143688
+ );
143689
+ }
143647
143690
  if (getHttpStatus(err) !== 422 || !params.comments?.length) throw err;
143648
143691
  const details = params.comments.map((c) => {
143649
143692
  const line = c.line ?? 0;
@@ -146616,6 +146659,10 @@ function detectProviderError(text) {
146616
146659
  }
146617
146660
  return null;
146618
146661
  }
146662
+ var ROUTER_KEYLIMIT_EXHAUSTED_PATTERN = /requires more credits.*?fewer max_tokens|requested up to \d+ tokens.*?can only afford/is;
146663
+ function isRouterKeylimitExhaustedError(text) {
146664
+ return ROUTER_KEYLIMIT_EXHAUSTED_PATTERN.test(text);
146665
+ }
146619
146666
 
146620
146667
  // utils/skills.ts
146621
146668
  import { spawnSync as spawnSync5 } from "node:child_process";
@@ -147154,6 +147201,12 @@ async function runClaude(params) {
147154
147201
  activityTimeout: 3e5,
147155
147202
  onActivityTimeout: params.onActivityTimeout,
147156
147203
  stdio: ["ignore", "pipe", "pipe"],
147204
+ // run claude in its own process group so SIGKILL on activity timeout /
147205
+ // outer cancellation reaches any subprocesses it spawns (rg, file
147206
+ // watchers, mcp transports, etc). claude itself is a node bundle so
147207
+ // there's no shim-orphan issue like opencode-ai/bin/opencode, but
147208
+ // detached + killGroup is the right default for any agent runtime.
147209
+ killGroup: true,
147157
147210
  onStdout: async (chunk) => {
147158
147211
  const text = chunk.toString();
147159
147212
  output += text;
@@ -147420,6 +147473,7 @@ async function installOpencodeCli() {
147420
147473
  installDependencies: true
147421
147474
  });
147422
147475
  }
147476
+ var PULLFROG_OPENCODE_OUTPUT_LIMIT = 5e3;
147423
147477
  function buildSecurityConfig(ctx, model) {
147424
147478
  const config3 = {
147425
147479
  permission: {
@@ -147512,6 +147566,9 @@ async function runOpenCode(params) {
147512
147566
  const taskDispatchByCallID = /* @__PURE__ */ new Map();
147513
147567
  const pendingTaskDispatches = [];
147514
147568
  const knownNonTaskCallIDs = /* @__PURE__ */ new Set();
147569
+ function isSubagentInFlight() {
147570
+ return taskDispatchByCallID.size > 0 || pendingTaskDispatches.length > 0;
147571
+ }
147515
147572
  function emitSubagentFinished(dispatch, status, output2, matchKind) {
147516
147573
  const subagentDuration = performance7.now() - dispatch.startedAt;
147517
147574
  const outputStr = typeof output2 === "string" ? output2 : "";
@@ -147764,6 +147821,20 @@ async function runOpenCode(params) {
147764
147821
  activityTimeout: 3e5,
147765
147822
  onActivityTimeout: params.onActivityTimeout,
147766
147823
  stdio: ["ignore", "pipe", "pipe"],
147824
+ // node_modules/opencode-ai/bin/opencode is a Node shim that spawnSyncs
147825
+ // the native opencode-<plat>-<arch> binary with stdio:"inherit". without
147826
+ // a process-group kill, SIGKILL hits only the shim, the native binary
147827
+ // is reparented to PID 1, holds our stdout pipe open, and `child.close`
147828
+ // never fires — producing zombie runs. detached + killGroup nukes the
147829
+ // whole tree.
147830
+ killGroup: true,
147831
+ // suspend the inner activity timer while a `task` subagent is in flight.
147832
+ // opencode's task tool encapsulates subagent execution in-process — the
147833
+ // subagent's internal events don't surface on the parent NDJSON stream,
147834
+ // so without this the 5min timeout would falsely fire mid-subagent.
147835
+ // suspend/resume is preferable to a heartbeat because there's no race
147836
+ // between a periodic tick and a subagent finishing between ticks.
147837
+ isPausedExternally: isSubagentInFlight,
147767
147838
  onStdout: async (chunk) => {
147768
147839
  const text = chunk.toString();
147769
147840
  output += text;
@@ -147935,6 +148006,7 @@ var opencode = agent({
147935
148006
  ...homeEnv,
147936
148007
  OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
147937
148008
  OPENCODE_PERMISSION: permissionOverride,
148009
+ OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: PULLFROG_OPENCODE_OUTPUT_LIMIT.toString(),
147938
148010
  GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY
147939
148011
  };
147940
148012
  const repoDir = process.cwd();
@@ -153352,6 +153424,24 @@ function formatBillingErrorSummary(error49, owner) {
153352
153424
  `[Add a card \u2192](${billingConsoleUrl(owner, "model-access")})`
153353
153425
  ].join("\n");
153354
153426
  }
153427
+ if (error49.code === "router_balance_exhausted") {
153428
+ return [
153429
+ "**Your Pullfrog Router balance is exhausted.**",
153430
+ "",
153431
+ "You have a card on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.",
153432
+ "",
153433
+ `[Top up balance \u2192](${billingConsoleUrl(owner, "billing")}) \xB7 [Enable auto-reload \u2192](${billingConsoleUrl(owner, "model-access")})`
153434
+ ].join("\n");
153435
+ }
153436
+ if (error49.code === "router_keylimit_exhausted") {
153437
+ return [
153438
+ "**This run was cut short \u2014 your Pullfrog Router balance ran out mid-run.**",
153439
+ "",
153440
+ "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.",
153441
+ "",
153442
+ `[Top up balance \u2192](${billingConsoleUrl(owner, "billing")}) \xB7 [Enable auto-reload \u2192](${billingConsoleUrl(owner, "model-access")})`
153443
+ ].join("\n");
153444
+ }
153355
153445
  if (error49.needsReauthentication) {
153356
153446
  const code = error49.declineCode ?? "authentication_required";
153357
153447
  return [
@@ -153592,6 +153682,7 @@ async function main() {
153592
153682
  setGitAuthServer(gitAuthServer);
153593
153683
  const resolvedModel = payload.proxyModel ? void 0 : resolveModel({ slug: payload.model });
153594
153684
  const agent2 = resolveAgent({ model: resolvedModel });
153685
+ toolState.model = payload.proxyModel ?? resolvedModel ?? payload.model;
153595
153686
  validateAgentApiKey({
153596
153687
  agent: agent2,
153597
153688
  model: payload.proxyModel ?? resolvedModel ?? payload.model,
@@ -153835,8 +153926,9 @@ ${instructions.user}` : null,
153835
153926
  todoTracker?.cancel();
153836
153927
  killTrackedChildren();
153837
153928
  log.error(errorMessage);
153929
+ const billingError = isRouterKeylimitExhaustedError(errorMessage) ? new BillingError(errorMessage, { code: "router_keylimit_exhausted" }) : null;
153838
153930
  try {
153839
- const errorSummary = `### \u274C Pullfrog failed
153931
+ const errorSummary = billingError ? formatBillingErrorSummary(billingError, runContext.repo.owner) : `### \u274C Pullfrog failed
153840
153932
 
153841
153933
  \`\`\`
153842
153934
  ${errorMessage}
@@ -153847,7 +153939,8 @@ ${errorMessage}
153847
153939
  } catch {
153848
153940
  }
153849
153941
  try {
153850
- await reportErrorToComment({ toolState, error: errorMessage });
153942
+ const commentBody = billingError ? formatBillingErrorSummary(billingError, runContext.repo.owner) : errorMessage;
153943
+ await reportErrorToComment({ toolState, error: commentBody });
153851
153944
  } catch {
153852
153945
  }
153853
153946
  if (toolContext) {
package/dist/internal.js CHANGED
@@ -805,7 +805,11 @@ var modes = computeModes("opencode");
805
805
  var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
806
806
  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>`;
807
807
  function formatModelLabel(slug) {
808
- const alias = resolveDisplayAlias(slug);
808
+ const alias = resolveDisplayAlias(slug) ?? // reverse-lookup: when the caller passes an effective model (proxy or
809
+ // resolved target like "openrouter/anthropic/claude-opus-4.7") instead of
810
+ // a stored alias slug, find the alias whose resolve target matches so we
811
+ // still render a friendly display name.
812
+ modelAliases.find((a) => a.resolve === slug || a.openRouterResolve === slug);
809
813
  if (!alias) return `\`${slug}\``;
810
814
  return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
811
815
  }
@@ -1,5 +1,18 @@
1
1
  import type { RestEndpointMethodTypes } from "@octokit/rest";
2
2
  import type { ToolContext } from "./server.ts";
3
+ /**
4
+ * detect GitHub's generic server-side 422 ("An internal error occurred,
5
+ * please try again.") that sometimes fires on `POST /pulls/{n}/reviews`.
6
+ *
7
+ * the body is stable across occurrences and distinct from every other 422
8
+ * cause we care about (anchor validation, body length, malformed suggestion
9
+ * blocks) — those all cite the specific problem. treating this as a
10
+ * transient server error unlocks bounded in-tool retry instead of surfacing
11
+ * it to the agent with the generic "likely causes (1)(2)(3)" prompt, which
12
+ * induces whack-a-mole comment dropping on content that was never the issue.
13
+ */
14
+ export declare function isTransientReviewError(err: unknown): boolean;
15
+ export declare const TRANSIENT_REVIEW_RETRY_DELAYS_MS: number[];
3
16
  export type CommentableLines = {
4
17
  RIGHT: Set<number>;
5
18
  LEFT: Set<number>;
@@ -5,7 +5,7 @@ import type { ToolContext } from "../mcp/server.ts";
5
5
  * are created during the run. Strings only (GraphQL node IDs).
6
6
  * Keep in sync with `STRING_FIELDS` in `app/api/workflow-run/[runId]/route.ts`.
7
7
  */
8
- export type WorkflowRunArtifactPatchKey = "prNodeId" | "issueNodeId" | "reviewNodeId" | "planCommentNodeId" | "summaryCommentNodeId" | "summarySnapshot";
8
+ export type WorkflowRunArtifactPatchKey = "prNodeId" | "issueNodeId" | "reviewNodeId" | "planCommentNodeId" | "summarySnapshot";
9
9
  /**
10
10
  * Usage fields — aggregated across all agent calls and PATCHed once at
11
11
  * end-of-run. Token counts are Int4 on the DB side (ample for any realistic
@@ -1 +1,2 @@
1
1
  export declare function detectProviderError(text: string): string | null;
2
+ export declare function isRouterKeylimitExhaustedError(text: string): boolean;
@@ -1,6 +1,12 @@
1
1
  export type RetryOptions = {
2
2
  maxAttempts?: number;
3
3
  delayMs?: number;
4
+ /**
5
+ * explicit delay schedule — one entry per retry (length N ⇒ N+1 attempts).
6
+ * when set, overrides `maxAttempts` and `delayMs`. e.g. `[1_000, 3_000]`
7
+ * means up to 3 attempts, sleeping 1s before retry 2 and 3s before retry 3.
8
+ */
9
+ delaysMs?: readonly number[];
4
10
  shouldRetry?: (error: unknown) => boolean;
5
11
  label?: string;
6
12
  };
@@ -26,6 +26,8 @@ export interface SpawnOptions {
26
26
  stdio?: ("pipe" | "ignore" | "inherit")[];
27
27
  onStdout?: (chunk: string) => void;
28
28
  onStderr?: (chunk: string) => void;
29
+ killGroup?: boolean;
30
+ isPausedExternally?: () => boolean;
29
31
  }
30
32
  export interface SpawnResult {
31
33
  stdout: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pullfrog",
3
- "version": "0.0.205",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "pullfrog": "dist/cli.mjs",