pullfrog 0.1.12 → 0.1.14

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
@@ -144249,7 +144249,7 @@ var import_semver = __toESM(require_semver2(), 1);
144249
144249
  // package.json
144250
144250
  var package_default = {
144251
144251
  name: "pullfrog",
144252
- version: "0.1.12",
144252
+ version: "0.1.14",
144253
144253
  type: "module",
144254
144254
  bin: {
144255
144255
  pullfrog: "dist/cli.mjs",
@@ -144831,8 +144831,8 @@ async function $git(subcommand, args2, options) {
144831
144831
  activityTimeout: 0
144832
144832
  });
144833
144833
  if (result.stderr.includes("askpass-compromised")) {
144834
- log.info("askpass code was already consumed \u2014 token has been revoked");
144835
- throw new Error("git auth failed \u2014 askpass code was already consumed, token revoked");
144834
+ log.info("askpass code was replayed after revoke \u2014 token has been revoked");
144835
+ throw new Error("git auth failed \u2014 askpass code was replayed after revoke, token revoked");
144836
144836
  }
144837
144837
  if (result.exitCode !== 0) {
144838
144838
  const stderr = result.stderr.trim();
@@ -144849,6 +144849,7 @@ ${stdout}` : stderr || stdout || "(no output)";
144849
144849
  stderr: result.stderr.trim()
144850
144850
  };
144851
144851
  } finally {
144852
+ authServer.revoke(code);
144852
144853
  try {
144853
144854
  unlinkSync(scriptPath);
144854
144855
  } catch {
@@ -145120,7 +145121,13 @@ var TRANSIENT_PATTERNS = [
145120
145121
  /HTTP 5\d\d/,
145121
145122
  /returned error: 5\d\d/i,
145122
145123
  /HTTP 429/,
145123
- /returned error: 429/i
145124
+ /returned error: 429/i,
145125
+ // github installation tokens can 401 for seconds after minting while
145126
+ // replicating (@octokit/auth-app retries the same class). git push
145127
+ // surfaces it as "Invalid username or token", distinct from 403
145128
+ // permission denied — safe to backoff-retry with the same token.
145129
+ /Invalid username or token/,
145130
+ /Authentication failed for 'https:\/\/github\.com\//
145124
145131
  ];
145125
145132
  function classifyPushError(msg) {
145126
145133
  if (CONCURRENT_PUSH_PATTERNS.some((p2) => msg.includes(p2))) return "concurrent-push";
@@ -149153,19 +149160,22 @@ async function installFromNpmTarball(params) {
149153
149160
  }
149154
149161
 
149155
149162
  // utils/providerErrors.ts
149163
+ var PROVIDER_BILLING_EXHAUSTED_LABEL = "provider billing exhausted";
149156
149164
  var statusKey = `\\b(?:status[_ ]?code|http[_ ]?status|status)["']?\\s*[:=]\\s*["']?`;
149157
149165
  var PROVIDER_ERROR_PATTERNS = [
149158
149166
  // billing-payload patterns come BEFORE bare status-code patterns. providers
149159
149167
  // commonly return 401 / 429 for billing/quota exhaustion (OpenCode Zen
149160
149168
  // `CreditsError` / `FreeUsageLimitError`, Gemini `RESOURCE_EXHAUSTED` +
149161
- // "spending cap", Anthropic "Insufficient balance"). these are non-retryable
149162
- // and require user-billing action — distinct from a transient auth error or
149163
- // rate-limit. status-code patterns would otherwise win and surface
149164
- // "auth error (401)" / "rate limited (429)" with no billing hint. see #778.
149165
- { regex: /\bCreditsError\b/, label: "provider billing exhausted" },
149166
- { regex: /\bFreeUsageLimitError\b/, label: "provider billing exhausted" },
149167
- { regex: /Insufficient balance/i, label: "provider billing exhausted" },
149168
- { regex: /spending cap/i, label: "provider billing exhausted" },
149169
+ // "spending cap", Anthropic "Insufficient balance" / "credit balance is
149170
+ // too low"). these are non-retryable and require user-billing action —
149171
+ // distinct from a transient auth error or rate-limit. status-code patterns
149172
+ // would otherwise win and surface "auth error (401)" / "rate limited (429)"
149173
+ // with no billing hint. see #778, #835.
149174
+ { regex: /\bCreditsError\b/, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
149175
+ { regex: /\bFreeUsageLimitError\b/, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
149176
+ { regex: /Insufficient balance/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
149177
+ { regex: /credit balance is too low/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
149178
+ { regex: /spending cap/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
149169
149179
  // auth patterns must come BEFORE rate-limit patterns. OpenRouter 401 error
149170
149180
  // payloads carry `x-ratelimit-*` response headers in the dump, and the
149171
149181
  // free-form rate-limit regex below would otherwise win on word-boundary
@@ -149236,6 +149246,13 @@ var ROUTER_KEYLIMIT_EXHAUSTED_PATTERN = /requires more credits.*?fewer max_token
149236
149246
  function isRouterKeylimitExhaustedError(text) {
149237
149247
  return ROUTER_KEYLIMIT_EXHAUSTED_PATTERN.test(text);
149238
149248
  }
149249
+ function isProviderBillingExhausted(text) {
149250
+ return findProviderErrorMatch(text)?.label === PROVIDER_BILLING_EXHAUSTED_LABEL;
149251
+ }
149252
+ function extractProviderId(text) {
149253
+ const match3 = text.match(/\bproviderID=([a-z0-9_-]+)/i);
149254
+ return match3 ? match3[1].toLowerCase() : null;
149255
+ }
149239
149256
 
149240
149257
  // utils/skills.ts
149241
149258
  import { spawnSync as spawnSync5 } from "node:child_process";
@@ -151445,8 +151462,7 @@ import { randomUUID as randomUUID4 } from "node:crypto";
151445
151462
  import { writeFileSync as writeFileSync12 } from "node:fs";
151446
151463
  import { createServer as createServer2 } from "node:http";
151447
151464
  import { join as join16 } from "node:path";
151448
- var CODE_TTL_MS = 5 * 60 * 1e3;
151449
- var TAMPER_WINDOW_MS = 6e4;
151465
+ var REVOKED_TRAP_MS = 6e4;
151450
151466
  function revokeGitHubToken(token) {
151451
151467
  fetch("https://api.github.com/installation/token", {
151452
151468
  method: "DELETE",
@@ -151477,18 +151493,14 @@ async function startGitAuthServer(tmpdir4) {
151477
151493
  res.writeHead(404).end();
151478
151494
  return;
151479
151495
  }
151480
- if (entry.state === "pending") {
151481
- entry.state = "consumed";
151482
- clearTimeout(entry.timeout);
151483
- entry.timeout = setTimeout(() => codes.delete(code), TAMPER_WINDOW_MS);
151484
- entry.timeout.unref();
151496
+ if (entry.state === "active") {
151485
151497
  res.writeHead(200, { "Content-Type": "text/plain" });
151486
151498
  res.end(entry.token);
151487
151499
  return;
151488
151500
  }
151489
- log.info("askpass code used twice \u2014 revoking token");
151501
+ log.info("askpass code used after revoke \u2014 revoking token");
151490
151502
  revokeGitHubToken(entry.token);
151491
- clearTimeout(entry.timeout);
151503
+ if (entry.timeout) clearTimeout(entry.timeout);
151492
151504
  codes.delete(code);
151493
151505
  res.writeHead(409, { "Content-Type": "text/plain" });
151494
151506
  res.end("compromised");
@@ -151505,14 +151517,16 @@ async function startGitAuthServer(tmpdir4) {
151505
151517
  log.debug(`git auth server listening on 127.0.0.1:${port}`);
151506
151518
  function register4(token) {
151507
151519
  const code = randomUUID4();
151508
- const timeout = setTimeout(() => {
151509
- codes.delete(code);
151510
- log.debug(`git auth code expired: ${code.slice(0, 8)}...`);
151511
- }, CODE_TTL_MS);
151512
- timeout.unref();
151513
- codes.set(code, { token, state: "pending", timeout });
151520
+ codes.set(code, { token, state: "active" });
151514
151521
  return code;
151515
151522
  }
151523
+ function revoke(code) {
151524
+ const entry = codes.get(code);
151525
+ if (!entry) return;
151526
+ entry.state = "revoked";
151527
+ entry.timeout = setTimeout(() => codes.delete(code), REVOKED_TRAP_MS);
151528
+ entry.timeout.unref();
151529
+ }
151516
151530
  function writeAskpassScript(code) {
151517
151531
  const scriptId = randomUUID4();
151518
151532
  const scriptName = `askpass-${scriptId}.js`;
@@ -151526,17 +151540,15 @@ async function startGitAuthServer(tmpdir4) {
151526
151540
  `if(r.statusCode===409){process.stderr.write("askpass-compromised\\n");process.exit(1)}`,
151527
151541
  `if(r.statusCode!==200){process.exit(1)}`,
151528
151542
  `var d="";r.on("data",function(c){d+=c});`,
151529
- `r.on("end",function(){`,
151530
- `process.stdout.write(d+"\\n");`,
151531
- `try{require("fs").unlinkSync("${scriptPath.replace(/\\/g, "\\\\")}")}catch(e){}`,
151532
- `})}).on("error",function(){process.exit(1)})}`
151543
+ `r.on("end",function(){process.stdout.write(d+"\\n")})`,
151544
+ `}).on("error",function(){process.exit(1)})}`
151533
151545
  ].join("\n");
151534
151546
  writeFileSync12(scriptPath, content, { mode: 448 });
151535
151547
  return scriptPath;
151536
151548
  }
151537
151549
  async function close() {
151538
151550
  for (const entry of codes.values()) {
151539
- clearTimeout(entry.timeout);
151551
+ if (entry.timeout) clearTimeout(entry.timeout);
151540
151552
  }
151541
151553
  codes.clear();
151542
151554
  await new Promise((resolve3) => server.close(() => resolve3()));
@@ -151545,6 +151557,7 @@ async function startGitAuthServer(tmpdir4) {
151545
151557
  return {
151546
151558
  port,
151547
151559
  register: register4,
151560
+ revoke,
151548
151561
  writeAskpassScript,
151549
151562
  close,
151550
151563
  [Symbol.asyncDispose]: close
@@ -156717,6 +156730,36 @@ async function resolveRunContextData(params) {
156717
156730
  function isProviderModelNotFoundError(message) {
156718
156731
  return message.includes("ProviderModelNotFoundError");
156719
156732
  }
156733
+ var PROVIDER_BILLING_URLS = {
156734
+ deepseek: "https://platform.deepseek.com/top_up",
156735
+ anthropic: "https://console.anthropic.com/settings/billing",
156736
+ openai: "https://platform.openai.com/account/billing",
156737
+ google: "https://aistudio.google.com/usage",
156738
+ opencode: "https://opencode.ai/zen"
156739
+ };
156740
+ function detectProviderId(message) {
156741
+ const harnessId = extractProviderId(message);
156742
+ if (harnessId) return harnessId;
156743
+ if (/credit balance is too low/i.test(message)) return "anthropic";
156744
+ return null;
156745
+ }
156746
+ function formatProviderBillingExhausted(input) {
156747
+ const providerId = detectProviderId(input.errorMessage);
156748
+ const dashboardUrl = providerId ? PROVIDER_BILLING_URLS[providerId] : void 0;
156749
+ const headline = providerId ? `**Your \`${providerId}\` account is out of credit.**` : "**Your provider account is out of credit.**";
156750
+ const cta = dashboardUrl ? `[Top up \`${providerId}\` \u2192](${dashboardUrl})` : "Top up your provider account, then re-trigger Pullfrog.";
156751
+ return [
156752
+ headline,
156753
+ "",
156754
+ "Pullfrog detected a billing-exhausted response from your provider \u2014 the agent stopped before completing this run.",
156755
+ "",
156756
+ cta,
156757
+ "",
156758
+ `\`\`\`
156759
+ ${input.errorMessage}
156760
+ \`\`\``
156761
+ ].join("\n");
156762
+ }
156720
156763
  function formatProviderModelNotFoundSummary(input) {
156721
156764
  return `Pullfrog's free fallback model is no longer available in OpenCode's catalog. Add an API key for your configured model in the Pullfrog console for \`${input.owner}/${input.name}\`, or contact support if this persists.
156722
156765
 
@@ -156736,6 +156779,12 @@ function renderRunError(input) {
156736
156779
  isHang: true,
156737
156780
  errorMessage: input.errorMessage
156738
156781
  }) : null;
156782
+ if (isProviderBillingExhausted(input.errorMessage)) {
156783
+ const body = formatProviderBillingExhausted({ errorMessage: input.errorMessage });
156784
+ return { summary: `### \u274C Pullfrog failed
156785
+
156786
+ ${body}`, comment: body };
156787
+ }
156739
156788
  const apiKeySource = hangBody ?? input.errorMessage;
156740
156789
  const apiKeyErrorSummary = isApiKeyAuthError(apiKeySource) ? formatApiKeyErrorSummary({
156741
156790
  owner: input.repo.owner,
@@ -156859,12 +156908,14 @@ async function finalizeSuccessRun(input) {
156859
156908
  repo: input.repo,
156860
156909
  agentDiagnostic: input.toolState.agentDiagnostic
156861
156910
  }) : null;
156862
- if (rendered && input.toolState.progressComment) {
156863
- await reportErrorToComment({ toolState: input.toolState, error: rendered.comment }).catch(
156864
- (error49) => {
156865
- log.debug(`failure error report failed: ${error49}`);
156866
- }
156867
- );
156911
+ if (rendered) {
156912
+ await reportErrorToComment({
156913
+ toolState: input.toolState,
156914
+ error: rendered.comment,
156915
+ createIfMissing: true
156916
+ }).catch((error49) => {
156917
+ log.debug(`failure error report failed: ${error49}`);
156918
+ });
156868
156919
  }
156869
156920
  if (input.result.success && input.toolState.progressComment && !input.toolState.finalSummaryWritten) {
156870
156921
  await deleteProgressComment(input.toolContext).catch((error49) => {
@@ -156896,7 +156947,11 @@ async function writeRunErrorOutputs(input) {
156896
156947
  } catch {
156897
156948
  }
156898
156949
  try {
156899
- await reportErrorToComment({ toolState: input.toolState, error: input.rendered.comment });
156950
+ await reportErrorToComment({
156951
+ toolState: input.toolState,
156952
+ error: input.rendered.comment,
156953
+ createIfMissing: true
156954
+ });
156900
156955
  } catch {
156901
156956
  }
156902
156957
  }
@@ -158384,7 +158439,7 @@ async function run2() {
158384
158439
  }
158385
158440
 
158386
158441
  // cli.ts
158387
- var VERSION10 = "0.1.12";
158442
+ var VERSION10 = "0.1.14";
158388
158443
  var bin = basename2(process.argv[1] || "");
158389
158444
  var PROG = bin === "pf" || bin === "pullfrog" ? bin : "pullfrog";
158390
158445
  var rawArgs = process.argv.slice(2);
package/dist/index.js CHANGED
@@ -142509,7 +142509,7 @@ var import_semver = __toESM(require_semver2(), 1);
142509
142509
  // package.json
142510
142510
  var package_default = {
142511
142511
  name: "pullfrog",
142512
- version: "0.1.12",
142512
+ version: "0.1.14",
142513
142513
  type: "module",
142514
142514
  bin: {
142515
142515
  pullfrog: "dist/cli.mjs",
@@ -143091,8 +143091,8 @@ async function $git(subcommand, args2, options) {
143091
143091
  activityTimeout: 0
143092
143092
  });
143093
143093
  if (result.stderr.includes("askpass-compromised")) {
143094
- log.info("askpass code was already consumed \u2014 token has been revoked");
143095
- throw new Error("git auth failed \u2014 askpass code was already consumed, token revoked");
143094
+ log.info("askpass code was replayed after revoke \u2014 token has been revoked");
143095
+ throw new Error("git auth failed \u2014 askpass code was replayed after revoke, token revoked");
143096
143096
  }
143097
143097
  if (result.exitCode !== 0) {
143098
143098
  const stderr = result.stderr.trim();
@@ -143109,6 +143109,7 @@ ${stdout}` : stderr || stdout || "(no output)";
143109
143109
  stderr: result.stderr.trim()
143110
143110
  };
143111
143111
  } finally {
143112
+ authServer.revoke(code);
143112
143113
  try {
143113
143114
  unlinkSync(scriptPath);
143114
143115
  } catch {
@@ -143380,7 +143381,13 @@ var TRANSIENT_PATTERNS = [
143380
143381
  /HTTP 5\d\d/,
143381
143382
  /returned error: 5\d\d/i,
143382
143383
  /HTTP 429/,
143383
- /returned error: 429/i
143384
+ /returned error: 429/i,
143385
+ // github installation tokens can 401 for seconds after minting while
143386
+ // replicating (@octokit/auth-app retries the same class). git push
143387
+ // surfaces it as "Invalid username or token", distinct from 403
143388
+ // permission denied — safe to backoff-retry with the same token.
143389
+ /Invalid username or token/,
143390
+ /Authentication failed for 'https:\/\/github\.com\//
143384
143391
  ];
143385
143392
  function classifyPushError(msg) {
143386
143393
  if (CONCURRENT_PUSH_PATTERNS.some((p) => msg.includes(p))) return "concurrent-push";
@@ -147413,19 +147420,22 @@ async function installFromNpmTarball(params) {
147413
147420
  }
147414
147421
 
147415
147422
  // utils/providerErrors.ts
147423
+ var PROVIDER_BILLING_EXHAUSTED_LABEL = "provider billing exhausted";
147416
147424
  var statusKey = `\\b(?:status[_ ]?code|http[_ ]?status|status)["']?\\s*[:=]\\s*["']?`;
147417
147425
  var PROVIDER_ERROR_PATTERNS = [
147418
147426
  // billing-payload patterns come BEFORE bare status-code patterns. providers
147419
147427
  // commonly return 401 / 429 for billing/quota exhaustion (OpenCode Zen
147420
147428
  // `CreditsError` / `FreeUsageLimitError`, Gemini `RESOURCE_EXHAUSTED` +
147421
- // "spending cap", Anthropic "Insufficient balance"). these are non-retryable
147422
- // and require user-billing action — distinct from a transient auth error or
147423
- // rate-limit. status-code patterns would otherwise win and surface
147424
- // "auth error (401)" / "rate limited (429)" with no billing hint. see #778.
147425
- { regex: /\bCreditsError\b/, label: "provider billing exhausted" },
147426
- { regex: /\bFreeUsageLimitError\b/, label: "provider billing exhausted" },
147427
- { regex: /Insufficient balance/i, label: "provider billing exhausted" },
147428
- { regex: /spending cap/i, label: "provider billing exhausted" },
147429
+ // "spending cap", Anthropic "Insufficient balance" / "credit balance is
147430
+ // too low"). these are non-retryable and require user-billing action —
147431
+ // distinct from a transient auth error or rate-limit. status-code patterns
147432
+ // would otherwise win and surface "auth error (401)" / "rate limited (429)"
147433
+ // with no billing hint. see #778, #835.
147434
+ { regex: /\bCreditsError\b/, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
147435
+ { regex: /\bFreeUsageLimitError\b/, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
147436
+ { regex: /Insufficient balance/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
147437
+ { regex: /credit balance is too low/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
147438
+ { regex: /spending cap/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
147429
147439
  // auth patterns must come BEFORE rate-limit patterns. OpenRouter 401 error
147430
147440
  // payloads carry `x-ratelimit-*` response headers in the dump, and the
147431
147441
  // free-form rate-limit regex below would otherwise win on word-boundary
@@ -147496,6 +147506,13 @@ var ROUTER_KEYLIMIT_EXHAUSTED_PATTERN = /requires more credits.*?fewer max_token
147496
147506
  function isRouterKeylimitExhaustedError(text) {
147497
147507
  return ROUTER_KEYLIMIT_EXHAUSTED_PATTERN.test(text);
147498
147508
  }
147509
+ function isProviderBillingExhausted(text) {
147510
+ return findProviderErrorMatch(text)?.label === PROVIDER_BILLING_EXHAUSTED_LABEL;
147511
+ }
147512
+ function extractProviderId(text) {
147513
+ const match3 = text.match(/\bproviderID=([a-z0-9_-]+)/i);
147514
+ return match3 ? match3[1].toLowerCase() : null;
147515
+ }
147499
147516
 
147500
147517
  // utils/skills.ts
147501
147518
  import { spawnSync as spawnSync5 } from "node:child_process";
@@ -149705,8 +149722,7 @@ import { randomUUID as randomUUID4 } from "node:crypto";
149705
149722
  import { writeFileSync as writeFileSync11 } from "node:fs";
149706
149723
  import { createServer as createServer2 } from "node:http";
149707
149724
  import { join as join15 } from "node:path";
149708
- var CODE_TTL_MS = 5 * 60 * 1e3;
149709
- var TAMPER_WINDOW_MS = 6e4;
149725
+ var REVOKED_TRAP_MS = 6e4;
149710
149726
  function revokeGitHubToken(token) {
149711
149727
  fetch("https://api.github.com/installation/token", {
149712
149728
  method: "DELETE",
@@ -149737,18 +149753,14 @@ async function startGitAuthServer(tmpdir3) {
149737
149753
  res.writeHead(404).end();
149738
149754
  return;
149739
149755
  }
149740
- if (entry.state === "pending") {
149741
- entry.state = "consumed";
149742
- clearTimeout(entry.timeout);
149743
- entry.timeout = setTimeout(() => codes.delete(code), TAMPER_WINDOW_MS);
149744
- entry.timeout.unref();
149756
+ if (entry.state === "active") {
149745
149757
  res.writeHead(200, { "Content-Type": "text/plain" });
149746
149758
  res.end(entry.token);
149747
149759
  return;
149748
149760
  }
149749
- log.info("askpass code used twice \u2014 revoking token");
149761
+ log.info("askpass code used after revoke \u2014 revoking token");
149750
149762
  revokeGitHubToken(entry.token);
149751
- clearTimeout(entry.timeout);
149763
+ if (entry.timeout) clearTimeout(entry.timeout);
149752
149764
  codes.delete(code);
149753
149765
  res.writeHead(409, { "Content-Type": "text/plain" });
149754
149766
  res.end("compromised");
@@ -149765,14 +149777,16 @@ async function startGitAuthServer(tmpdir3) {
149765
149777
  log.debug(`git auth server listening on 127.0.0.1:${port}`);
149766
149778
  function register4(token) {
149767
149779
  const code = randomUUID4();
149768
- const timeout = setTimeout(() => {
149769
- codes.delete(code);
149770
- log.debug(`git auth code expired: ${code.slice(0, 8)}...`);
149771
- }, CODE_TTL_MS);
149772
- timeout.unref();
149773
- codes.set(code, { token, state: "pending", timeout });
149780
+ codes.set(code, { token, state: "active" });
149774
149781
  return code;
149775
149782
  }
149783
+ function revoke(code) {
149784
+ const entry = codes.get(code);
149785
+ if (!entry) return;
149786
+ entry.state = "revoked";
149787
+ entry.timeout = setTimeout(() => codes.delete(code), REVOKED_TRAP_MS);
149788
+ entry.timeout.unref();
149789
+ }
149776
149790
  function writeAskpassScript(code) {
149777
149791
  const scriptId = randomUUID4();
149778
149792
  const scriptName = `askpass-${scriptId}.js`;
@@ -149786,17 +149800,15 @@ async function startGitAuthServer(tmpdir3) {
149786
149800
  `if(r.statusCode===409){process.stderr.write("askpass-compromised\\n");process.exit(1)}`,
149787
149801
  `if(r.statusCode!==200){process.exit(1)}`,
149788
149802
  `var d="";r.on("data",function(c){d+=c});`,
149789
- `r.on("end",function(){`,
149790
- `process.stdout.write(d+"\\n");`,
149791
- `try{require("fs").unlinkSync("${scriptPath.replace(/\\/g, "\\\\")}")}catch(e){}`,
149792
- `})}).on("error",function(){process.exit(1)})}`
149803
+ `r.on("end",function(){process.stdout.write(d+"\\n")})`,
149804
+ `}).on("error",function(){process.exit(1)})}`
149793
149805
  ].join("\n");
149794
149806
  writeFileSync11(scriptPath, content, { mode: 448 });
149795
149807
  return scriptPath;
149796
149808
  }
149797
149809
  async function close() {
149798
149810
  for (const entry of codes.values()) {
149799
- clearTimeout(entry.timeout);
149811
+ if (entry.timeout) clearTimeout(entry.timeout);
149800
149812
  }
149801
149813
  codes.clear();
149802
149814
  await new Promise((resolve3) => server.close(() => resolve3()));
@@ -149805,6 +149817,7 @@ async function startGitAuthServer(tmpdir3) {
149805
149817
  return {
149806
149818
  port,
149807
149819
  register: register4,
149820
+ revoke,
149808
149821
  writeAskpassScript,
149809
149822
  close,
149810
149823
  [Symbol.asyncDispose]: close
@@ -154977,6 +154990,36 @@ async function resolveRunContextData(params) {
154977
154990
  function isProviderModelNotFoundError(message) {
154978
154991
  return message.includes("ProviderModelNotFoundError");
154979
154992
  }
154993
+ var PROVIDER_BILLING_URLS = {
154994
+ deepseek: "https://platform.deepseek.com/top_up",
154995
+ anthropic: "https://console.anthropic.com/settings/billing",
154996
+ openai: "https://platform.openai.com/account/billing",
154997
+ google: "https://aistudio.google.com/usage",
154998
+ opencode: "https://opencode.ai/zen"
154999
+ };
155000
+ function detectProviderId(message) {
155001
+ const harnessId = extractProviderId(message);
155002
+ if (harnessId) return harnessId;
155003
+ if (/credit balance is too low/i.test(message)) return "anthropic";
155004
+ return null;
155005
+ }
155006
+ function formatProviderBillingExhausted(input) {
155007
+ const providerId = detectProviderId(input.errorMessage);
155008
+ const dashboardUrl = providerId ? PROVIDER_BILLING_URLS[providerId] : void 0;
155009
+ const headline = providerId ? `**Your \`${providerId}\` account is out of credit.**` : "**Your provider account is out of credit.**";
155010
+ const cta = dashboardUrl ? `[Top up \`${providerId}\` \u2192](${dashboardUrl})` : "Top up your provider account, then re-trigger Pullfrog.";
155011
+ return [
155012
+ headline,
155013
+ "",
155014
+ "Pullfrog detected a billing-exhausted response from your provider \u2014 the agent stopped before completing this run.",
155015
+ "",
155016
+ cta,
155017
+ "",
155018
+ `\`\`\`
155019
+ ${input.errorMessage}
155020
+ \`\`\``
155021
+ ].join("\n");
155022
+ }
154980
155023
  function formatProviderModelNotFoundSummary(input) {
154981
155024
  return `Pullfrog's free fallback model is no longer available in OpenCode's catalog. Add an API key for your configured model in the Pullfrog console for \`${input.owner}/${input.name}\`, or contact support if this persists.
154982
155025
 
@@ -154996,6 +155039,12 @@ function renderRunError(input) {
154996
155039
  isHang: true,
154997
155040
  errorMessage: input.errorMessage
154998
155041
  }) : null;
155042
+ if (isProviderBillingExhausted(input.errorMessage)) {
155043
+ const body = formatProviderBillingExhausted({ errorMessage: input.errorMessage });
155044
+ return { summary: `### \u274C Pullfrog failed
155045
+
155046
+ ${body}`, comment: body };
155047
+ }
154999
155048
  const apiKeySource = hangBody ?? input.errorMessage;
155000
155049
  const apiKeyErrorSummary = isApiKeyAuthError(apiKeySource) ? formatApiKeyErrorSummary({
155001
155050
  owner: input.repo.owner,
@@ -155119,12 +155168,14 @@ async function finalizeSuccessRun(input) {
155119
155168
  repo: input.repo,
155120
155169
  agentDiagnostic: input.toolState.agentDiagnostic
155121
155170
  }) : null;
155122
- if (rendered && input.toolState.progressComment) {
155123
- await reportErrorToComment({ toolState: input.toolState, error: rendered.comment }).catch(
155124
- (error49) => {
155125
- log.debug(`failure error report failed: ${error49}`);
155126
- }
155127
- );
155171
+ if (rendered) {
155172
+ await reportErrorToComment({
155173
+ toolState: input.toolState,
155174
+ error: rendered.comment,
155175
+ createIfMissing: true
155176
+ }).catch((error49) => {
155177
+ log.debug(`failure error report failed: ${error49}`);
155178
+ });
155128
155179
  }
155129
155180
  if (input.result.success && input.toolState.progressComment && !input.toolState.finalSummaryWritten) {
155130
155181
  await deleteProgressComment(input.toolContext).catch((error49) => {
@@ -155156,7 +155207,11 @@ async function writeRunErrorOutputs(input) {
155156
155207
  } catch {
155157
155208
  }
155158
155209
  try {
155159
- await reportErrorToComment({ toolState: input.toolState, error: input.rendered.comment });
155210
+ await reportErrorToComment({
155211
+ toolState: input.toolState,
155212
+ error: input.rendered.comment,
155213
+ createIfMissing: true
155214
+ });
155160
155215
  } catch {
155161
155216
  }
155162
155217
  }
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * git authentication via GIT_ASKPASS.
3
3
  *
4
- * a localhost HTTP server serves tokens via single-use UUID codes.
5
- * each $git() call writes a unique askpass script with the server
6
- * port+code baked into the file bodyno secrets in subprocess env.
4
+ * a localhost HTTP server serves tokens via UUID codes whose lifetime is
5
+ * bounded by the parent $git() invocation: register() makes the code active,
6
+ * the script (and any sibling subprocesse.g. git-lfs pre-push) can fetch
7
+ * the token any number of times, and $git()'s finally calls revoke() to
8
+ * close the window. each $git() call writes a unique askpass script with
9
+ * the server port+code baked into the file body — no secrets in subprocess
10
+ * env. a replay of a revoked code trips a 409 and revokes the underlying
11
+ * github installation token.
7
12
  *
8
13
  * see wiki/askpass.md for full security documentation.
9
14
  */
@@ -35,9 +40,13 @@ export declare function setGitAuthServer(server: GitAuthServer): void;
35
40
  * a remote and need credentials. working-tree operations (checkout, merge)
36
41
  * use $() from shell.ts which has no token.
37
42
  *
38
- * per call: registers a one-time code with the auth server, writes a
39
- * unique askpass script with port+code baked in, spawns git with
40
- * GIT_ASKPASS pointing to the script, and deletes the script in finally.
43
+ * per call: registers a code with the auth server (valid for the lifetime
44
+ * of this invocation), writes a unique askpass script with port+code baked
45
+ * in, spawns git with GIT_ASKPASS pointing to the script. on completion,
46
+ * revokes the code and deletes the script in finally. multiple sibling
47
+ * askpass calls within one invocation (e.g. git itself + git-lfs pre-push)
48
+ * all see a valid code; replay attempts after finally trip a 409 and the
49
+ * server revokes the underlying github token as a tamper signal.
41
50
  *
42
51
  * @example
43
52
  * await $git("fetch", ["origin", "main"], { token });
@@ -1,16 +1,22 @@
1
1
  /**
2
2
  * ASKPASS-based git authentication server.
3
3
  *
4
- * serves tokens via a localhost HTTP server with single-use UUID codes.
4
+ * serves tokens via a localhost HTTP server with per-$git()-call UUID codes.
5
5
  * each $git() call gets a unique askpass script with the port+code baked in.
6
6
  * the token never appears in subprocess env — only the script file path.
7
7
  *
8
- * tamper-evident: if a code is used twice, the second request triggers
9
- * immediate token revocation via the GitHub API as a precaution.
8
+ * lifetime: the code is valid for as long as the $git() invocation is
9
+ * running. multiple askpass calls within one invocation (e.g. git's own
10
+ * fetch/push + a git-lfs pre-push hook that also authenticates) all
11
+ * succeed. $git() calls revoke(code) in finally; subsequent requests for
12
+ * a revoked code trigger immediate token revocation via the GitHub API
13
+ * as a tamper-evidence precaution (an agent replaying the code after the
14
+ * legitimate window has closed is the realistic attack we still catch).
10
15
  */
11
16
  export type GitAuthServer = {
12
17
  port: number;
13
18
  register: (token: string) => string;
19
+ revoke: (code: string) => void;
14
20
  writeAskpassScript: (code: string) => string;
15
21
  close: () => Promise<void>;
16
22
  [Symbol.asyncDispose]: () => Promise<void>;
@@ -1,3 +1,5 @@
1
+ /** Stable label for the BYOK provider-billing-exhausted classification. */
2
+ export declare const PROVIDER_BILLING_EXHAUSTED_LABEL = "provider billing exhausted";
1
3
  /**
2
4
  * Result of a provider-error scan: the classification label plus a
3
5
  * human-readable excerpt centered on the matched line. The excerpt is what
@@ -11,3 +13,19 @@ export type ProviderErrorMatch = {
11
13
  export declare function findProviderErrorMatch(text: string): ProviderErrorMatch | null;
12
14
  export declare function detectProviderError(text: string): string | null;
13
15
  export declare function isRouterKeylimitExhaustedError(text: string): boolean;
16
+ /**
17
+ * BYOK billing-exhausted: provider rejected the request because the user's
18
+ * provider wallet is empty (DeepSeek "Insufficient Balance", Anthropic
19
+ * "credit balance is too low", OpenCode Zen `CreditsError` /
20
+ * `FreeUsageLimitError`, Gemini "spending cap"). Distinct from
21
+ * `isRouterKeylimitExhaustedError` — that's Pullfrog's Router wallet, this
22
+ * is the user's own provider account.
23
+ */
24
+ export declare function isProviderBillingExhausted(text: string): boolean;
25
+ /**
26
+ * Extract `providerID=foo` from agent error logs (OpenCode emits this on
27
+ * `provider error detected (...)` lines). Returns the lowercase provider
28
+ * slug, or null when absent. Used to render a provider-specific dashboard
29
+ * link in the BYOK billing-exhausted summary.
30
+ */
31
+ export declare function extractProviderId(text: string): string | null;
@@ -3,22 +3,37 @@
3
3
  * pair of user-facing markdown bodies — one for the GitHub Actions job
4
4
  * summary tab, one for the PR progress comment.
5
5
  *
6
- * Four classifications, in priority order:
6
+ * Classifications, in dispatch order (first match wins; the api-key
7
+ * branch additionally folds in the activity-timeout hang body as a
8
+ * sub-source so a hang masking an api-key error still surfaces the api-key
9
+ * CTA):
7
10
  *
8
11
  * 1. `BillingError` — either the proxy-token mint already threw one (402
9
12
  * handled inline) or the agent runtime surfaced an OpenRouter
10
13
  * "key budget exhausted" string mid-run. Both render via
11
14
  * `formatBillingErrorSummary` so the user sees actionable copy.
12
15
  *
13
- * 2. Activity-timeout hang`errorMessage` starts with
14
- * `"activity timeout"` or `"agent still pending"`. The harness keeps
15
- * structured diagnostic state on `toolState.agentDiagnostic`;
16
- * `formatAgentHangBody` renders that as a markdown block.
16
+ * 2. BYOK provider billing-exhausted (#835)DeepSeek "Insufficient
17
+ * Balance", Anthropic "credit balance is too low", OpenCode Zen
18
+ * `CreditsError`, Gemini "spending cap". Checked before api-key auth
19
+ * because billing-exhausted responses often carry 401 status codes
20
+ * that `isApiKeyAuthError` would otherwise mis-classify.
17
21
  *
18
- * 3. API-key auth error — `isApiKeyAuthError` sniffs the raw error string;
19
- * `formatApiKeyErrorSummary` renders provider + console-link copy.
22
+ * 3. API-key auth error — `isApiKeyAuthError` sniffs the raw error string
23
+ * (or the activity-timeout hang body when present, since that's where
24
+ * the underlying provider error often lands); `formatApiKeyErrorSummary`
25
+ * renders provider + console-link copy.
20
26
  *
21
- * 4. Defaulta generic `❌ Pullfrog failed` block with the raw error
27
+ * 4. ProviderModelNotFoundErrorstale free-fallback model id no longer
28
+ * in the OpenCode catalog; renders a nudge to add a BYOK key.
29
+ *
30
+ * 5. Activity-timeout hang — `errorMessage` starts with
31
+ * `"activity timeout"` or `"agent still pending"` AND none of the
32
+ * above matched. The harness keeps structured diagnostic state on
33
+ * `toolState.agentDiagnostic`; `formatAgentHangBody` renders that as
34
+ * a markdown block.
35
+ *
36
+ * 6. Default — a generic `❌ Pullfrog failed` block with the raw error
22
37
  * message in a fenced code block. Same body for both surfaces.
23
38
  *
24
39
  * The hang body and the API-key body diverge between the two surfaces only
@@ -71,6 +71,13 @@ export declare function finalizeSuccessRun(input: {
71
71
  *
72
72
  * `lastProgressBody` and the usage table are appended to the summary so the
73
73
  * partial work the agent did before failing isn't lost.
74
+ *
75
+ * `createIfMissing: true` is symmetric with `finalizeSuccessRun` — silent
76
+ * triggers (IncrementalReview / pull_request_synchronize / auto-label) that
77
+ * throw past `finalizeSuccessRun` (e.g. timeout race kills the agent
78
+ * mid-billing-exhausted-retry) reach this catch path with no progress
79
+ * comment to update, and without `createIfMissing` the terminal error
80
+ * lands only in the GH job summary that most users never open. see #835.
74
81
  */
75
82
  export declare function writeRunErrorOutputs(input: {
76
83
  rendered: RenderedRunError;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pullfrog",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "pullfrog": "dist/cli.mjs",