pullfrog 0.1.13 → 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.13",
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 {
@@ -149159,19 +149160,22 @@ async function installFromNpmTarball(params) {
149159
149160
  }
149160
149161
 
149161
149162
  // utils/providerErrors.ts
149163
+ var PROVIDER_BILLING_EXHAUSTED_LABEL = "provider billing exhausted";
149162
149164
  var statusKey = `\\b(?:status[_ ]?code|http[_ ]?status|status)["']?\\s*[:=]\\s*["']?`;
149163
149165
  var PROVIDER_ERROR_PATTERNS = [
149164
149166
  // billing-payload patterns come BEFORE bare status-code patterns. providers
149165
149167
  // commonly return 401 / 429 for billing/quota exhaustion (OpenCode Zen
149166
149168
  // `CreditsError` / `FreeUsageLimitError`, Gemini `RESOURCE_EXHAUSTED` +
149167
- // "spending cap", Anthropic "Insufficient balance"). these are non-retryable
149168
- // and require user-billing action — distinct from a transient auth error or
149169
- // rate-limit. status-code patterns would otherwise win and surface
149170
- // "auth error (401)" / "rate limited (429)" with no billing hint. see #778.
149171
- { regex: /\bCreditsError\b/, label: "provider billing exhausted" },
149172
- { regex: /\bFreeUsageLimitError\b/, label: "provider billing exhausted" },
149173
- { regex: /Insufficient balance/i, label: "provider billing exhausted" },
149174
- { 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 },
149175
149179
  // auth patterns must come BEFORE rate-limit patterns. OpenRouter 401 error
149176
149180
  // payloads carry `x-ratelimit-*` response headers in the dump, and the
149177
149181
  // free-form rate-limit regex below would otherwise win on word-boundary
@@ -149242,6 +149246,13 @@ var ROUTER_KEYLIMIT_EXHAUSTED_PATTERN = /requires more credits.*?fewer max_token
149242
149246
  function isRouterKeylimitExhaustedError(text) {
149243
149247
  return ROUTER_KEYLIMIT_EXHAUSTED_PATTERN.test(text);
149244
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
+ }
149245
149256
 
149246
149257
  // utils/skills.ts
149247
149258
  import { spawnSync as spawnSync5 } from "node:child_process";
@@ -151451,8 +151462,7 @@ import { randomUUID as randomUUID4 } from "node:crypto";
151451
151462
  import { writeFileSync as writeFileSync12 } from "node:fs";
151452
151463
  import { createServer as createServer2 } from "node:http";
151453
151464
  import { join as join16 } from "node:path";
151454
- var CODE_TTL_MS = 5 * 60 * 1e3;
151455
- var TAMPER_WINDOW_MS = 6e4;
151465
+ var REVOKED_TRAP_MS = 6e4;
151456
151466
  function revokeGitHubToken(token) {
151457
151467
  fetch("https://api.github.com/installation/token", {
151458
151468
  method: "DELETE",
@@ -151483,18 +151493,14 @@ async function startGitAuthServer(tmpdir4) {
151483
151493
  res.writeHead(404).end();
151484
151494
  return;
151485
151495
  }
151486
- if (entry.state === "pending") {
151487
- entry.state = "consumed";
151488
- clearTimeout(entry.timeout);
151489
- entry.timeout = setTimeout(() => codes.delete(code), TAMPER_WINDOW_MS);
151490
- entry.timeout.unref();
151496
+ if (entry.state === "active") {
151491
151497
  res.writeHead(200, { "Content-Type": "text/plain" });
151492
151498
  res.end(entry.token);
151493
151499
  return;
151494
151500
  }
151495
- log.info("askpass code used twice \u2014 revoking token");
151501
+ log.info("askpass code used after revoke \u2014 revoking token");
151496
151502
  revokeGitHubToken(entry.token);
151497
- clearTimeout(entry.timeout);
151503
+ if (entry.timeout) clearTimeout(entry.timeout);
151498
151504
  codes.delete(code);
151499
151505
  res.writeHead(409, { "Content-Type": "text/plain" });
151500
151506
  res.end("compromised");
@@ -151511,14 +151517,16 @@ async function startGitAuthServer(tmpdir4) {
151511
151517
  log.debug(`git auth server listening on 127.0.0.1:${port}`);
151512
151518
  function register4(token) {
151513
151519
  const code = randomUUID4();
151514
- const timeout = setTimeout(() => {
151515
- codes.delete(code);
151516
- log.debug(`git auth code expired: ${code.slice(0, 8)}...`);
151517
- }, CODE_TTL_MS);
151518
- timeout.unref();
151519
- codes.set(code, { token, state: "pending", timeout });
151520
+ codes.set(code, { token, state: "active" });
151520
151521
  return code;
151521
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
+ }
151522
151530
  function writeAskpassScript(code) {
151523
151531
  const scriptId = randomUUID4();
151524
151532
  const scriptName = `askpass-${scriptId}.js`;
@@ -151532,17 +151540,15 @@ async function startGitAuthServer(tmpdir4) {
151532
151540
  `if(r.statusCode===409){process.stderr.write("askpass-compromised\\n");process.exit(1)}`,
151533
151541
  `if(r.statusCode!==200){process.exit(1)}`,
151534
151542
  `var d="";r.on("data",function(c){d+=c});`,
151535
- `r.on("end",function(){`,
151536
- `process.stdout.write(d+"\\n");`,
151537
- `try{require("fs").unlinkSync("${scriptPath.replace(/\\/g, "\\\\")}")}catch(e){}`,
151538
- `})}).on("error",function(){process.exit(1)})}`
151543
+ `r.on("end",function(){process.stdout.write(d+"\\n")})`,
151544
+ `}).on("error",function(){process.exit(1)})}`
151539
151545
  ].join("\n");
151540
151546
  writeFileSync12(scriptPath, content, { mode: 448 });
151541
151547
  return scriptPath;
151542
151548
  }
151543
151549
  async function close() {
151544
151550
  for (const entry of codes.values()) {
151545
- clearTimeout(entry.timeout);
151551
+ if (entry.timeout) clearTimeout(entry.timeout);
151546
151552
  }
151547
151553
  codes.clear();
151548
151554
  await new Promise((resolve3) => server.close(() => resolve3()));
@@ -151551,6 +151557,7 @@ async function startGitAuthServer(tmpdir4) {
151551
151557
  return {
151552
151558
  port,
151553
151559
  register: register4,
151560
+ revoke,
151554
151561
  writeAskpassScript,
151555
151562
  close,
151556
151563
  [Symbol.asyncDispose]: close
@@ -156723,6 +156730,36 @@ async function resolveRunContextData(params) {
156723
156730
  function isProviderModelNotFoundError(message) {
156724
156731
  return message.includes("ProviderModelNotFoundError");
156725
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
+ }
156726
156763
  function formatProviderModelNotFoundSummary(input) {
156727
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.
156728
156765
 
@@ -156742,6 +156779,12 @@ function renderRunError(input) {
156742
156779
  isHang: true,
156743
156780
  errorMessage: input.errorMessage
156744
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
+ }
156745
156788
  const apiKeySource = hangBody ?? input.errorMessage;
156746
156789
  const apiKeyErrorSummary = isApiKeyAuthError(apiKeySource) ? formatApiKeyErrorSummary({
156747
156790
  owner: input.repo.owner,
@@ -156865,12 +156908,14 @@ async function finalizeSuccessRun(input) {
156865
156908
  repo: input.repo,
156866
156909
  agentDiagnostic: input.toolState.agentDiagnostic
156867
156910
  }) : null;
156868
- if (rendered && input.toolState.progressComment) {
156869
- await reportErrorToComment({ toolState: input.toolState, error: rendered.comment }).catch(
156870
- (error49) => {
156871
- log.debug(`failure error report failed: ${error49}`);
156872
- }
156873
- );
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
+ });
156874
156919
  }
156875
156920
  if (input.result.success && input.toolState.progressComment && !input.toolState.finalSummaryWritten) {
156876
156921
  await deleteProgressComment(input.toolContext).catch((error49) => {
@@ -156902,7 +156947,11 @@ async function writeRunErrorOutputs(input) {
156902
156947
  } catch {
156903
156948
  }
156904
156949
  try {
156905
- 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
+ });
156906
156955
  } catch {
156907
156956
  }
156908
156957
  }
@@ -158390,7 +158439,7 @@ async function run2() {
158390
158439
  }
158391
158440
 
158392
158441
  // cli.ts
158393
- var VERSION10 = "0.1.13";
158442
+ var VERSION10 = "0.1.14";
158394
158443
  var bin = basename2(process.argv[1] || "");
158395
158444
  var PROG = bin === "pf" || bin === "pullfrog" ? bin : "pullfrog";
158396
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.13",
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 {
@@ -147419,19 +147420,22 @@ async function installFromNpmTarball(params) {
147419
147420
  }
147420
147421
 
147421
147422
  // utils/providerErrors.ts
147423
+ var PROVIDER_BILLING_EXHAUSTED_LABEL = "provider billing exhausted";
147422
147424
  var statusKey = `\\b(?:status[_ ]?code|http[_ ]?status|status)["']?\\s*[:=]\\s*["']?`;
147423
147425
  var PROVIDER_ERROR_PATTERNS = [
147424
147426
  // billing-payload patterns come BEFORE bare status-code patterns. providers
147425
147427
  // commonly return 401 / 429 for billing/quota exhaustion (OpenCode Zen
147426
147428
  // `CreditsError` / `FreeUsageLimitError`, Gemini `RESOURCE_EXHAUSTED` +
147427
- // "spending cap", Anthropic "Insufficient balance"). these are non-retryable
147428
- // and require user-billing action — distinct from a transient auth error or
147429
- // rate-limit. status-code patterns would otherwise win and surface
147430
- // "auth error (401)" / "rate limited (429)" with no billing hint. see #778.
147431
- { regex: /\bCreditsError\b/, label: "provider billing exhausted" },
147432
- { regex: /\bFreeUsageLimitError\b/, label: "provider billing exhausted" },
147433
- { regex: /Insufficient balance/i, label: "provider billing exhausted" },
147434
- { 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 },
147435
147439
  // auth patterns must come BEFORE rate-limit patterns. OpenRouter 401 error
147436
147440
  // payloads carry `x-ratelimit-*` response headers in the dump, and the
147437
147441
  // free-form rate-limit regex below would otherwise win on word-boundary
@@ -147502,6 +147506,13 @@ var ROUTER_KEYLIMIT_EXHAUSTED_PATTERN = /requires more credits.*?fewer max_token
147502
147506
  function isRouterKeylimitExhaustedError(text) {
147503
147507
  return ROUTER_KEYLIMIT_EXHAUSTED_PATTERN.test(text);
147504
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
+ }
147505
147516
 
147506
147517
  // utils/skills.ts
147507
147518
  import { spawnSync as spawnSync5 } from "node:child_process";
@@ -149711,8 +149722,7 @@ import { randomUUID as randomUUID4 } from "node:crypto";
149711
149722
  import { writeFileSync as writeFileSync11 } from "node:fs";
149712
149723
  import { createServer as createServer2 } from "node:http";
149713
149724
  import { join as join15 } from "node:path";
149714
- var CODE_TTL_MS = 5 * 60 * 1e3;
149715
- var TAMPER_WINDOW_MS = 6e4;
149725
+ var REVOKED_TRAP_MS = 6e4;
149716
149726
  function revokeGitHubToken(token) {
149717
149727
  fetch("https://api.github.com/installation/token", {
149718
149728
  method: "DELETE",
@@ -149743,18 +149753,14 @@ async function startGitAuthServer(tmpdir3) {
149743
149753
  res.writeHead(404).end();
149744
149754
  return;
149745
149755
  }
149746
- if (entry.state === "pending") {
149747
- entry.state = "consumed";
149748
- clearTimeout(entry.timeout);
149749
- entry.timeout = setTimeout(() => codes.delete(code), TAMPER_WINDOW_MS);
149750
- entry.timeout.unref();
149756
+ if (entry.state === "active") {
149751
149757
  res.writeHead(200, { "Content-Type": "text/plain" });
149752
149758
  res.end(entry.token);
149753
149759
  return;
149754
149760
  }
149755
- log.info("askpass code used twice \u2014 revoking token");
149761
+ log.info("askpass code used after revoke \u2014 revoking token");
149756
149762
  revokeGitHubToken(entry.token);
149757
- clearTimeout(entry.timeout);
149763
+ if (entry.timeout) clearTimeout(entry.timeout);
149758
149764
  codes.delete(code);
149759
149765
  res.writeHead(409, { "Content-Type": "text/plain" });
149760
149766
  res.end("compromised");
@@ -149771,14 +149777,16 @@ async function startGitAuthServer(tmpdir3) {
149771
149777
  log.debug(`git auth server listening on 127.0.0.1:${port}`);
149772
149778
  function register4(token) {
149773
149779
  const code = randomUUID4();
149774
- const timeout = setTimeout(() => {
149775
- codes.delete(code);
149776
- log.debug(`git auth code expired: ${code.slice(0, 8)}...`);
149777
- }, CODE_TTL_MS);
149778
- timeout.unref();
149779
- codes.set(code, { token, state: "pending", timeout });
149780
+ codes.set(code, { token, state: "active" });
149780
149781
  return code;
149781
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
+ }
149782
149790
  function writeAskpassScript(code) {
149783
149791
  const scriptId = randomUUID4();
149784
149792
  const scriptName = `askpass-${scriptId}.js`;
@@ -149792,17 +149800,15 @@ async function startGitAuthServer(tmpdir3) {
149792
149800
  `if(r.statusCode===409){process.stderr.write("askpass-compromised\\n");process.exit(1)}`,
149793
149801
  `if(r.statusCode!==200){process.exit(1)}`,
149794
149802
  `var d="";r.on("data",function(c){d+=c});`,
149795
- `r.on("end",function(){`,
149796
- `process.stdout.write(d+"\\n");`,
149797
- `try{require("fs").unlinkSync("${scriptPath.replace(/\\/g, "\\\\")}")}catch(e){}`,
149798
- `})}).on("error",function(){process.exit(1)})}`
149803
+ `r.on("end",function(){process.stdout.write(d+"\\n")})`,
149804
+ `}).on("error",function(){process.exit(1)})}`
149799
149805
  ].join("\n");
149800
149806
  writeFileSync11(scriptPath, content, { mode: 448 });
149801
149807
  return scriptPath;
149802
149808
  }
149803
149809
  async function close() {
149804
149810
  for (const entry of codes.values()) {
149805
- clearTimeout(entry.timeout);
149811
+ if (entry.timeout) clearTimeout(entry.timeout);
149806
149812
  }
149807
149813
  codes.clear();
149808
149814
  await new Promise((resolve3) => server.close(() => resolve3()));
@@ -149811,6 +149817,7 @@ async function startGitAuthServer(tmpdir3) {
149811
149817
  return {
149812
149818
  port,
149813
149819
  register: register4,
149820
+ revoke,
149814
149821
  writeAskpassScript,
149815
149822
  close,
149816
149823
  [Symbol.asyncDispose]: close
@@ -154983,6 +154990,36 @@ async function resolveRunContextData(params) {
154983
154990
  function isProviderModelNotFoundError(message) {
154984
154991
  return message.includes("ProviderModelNotFoundError");
154985
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
+ }
154986
155023
  function formatProviderModelNotFoundSummary(input) {
154987
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.
154988
155025
 
@@ -155002,6 +155039,12 @@ function renderRunError(input) {
155002
155039
  isHang: true,
155003
155040
  errorMessage: input.errorMessage
155004
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
+ }
155005
155048
  const apiKeySource = hangBody ?? input.errorMessage;
155006
155049
  const apiKeyErrorSummary = isApiKeyAuthError(apiKeySource) ? formatApiKeyErrorSummary({
155007
155050
  owner: input.repo.owner,
@@ -155125,12 +155168,14 @@ async function finalizeSuccessRun(input) {
155125
155168
  repo: input.repo,
155126
155169
  agentDiagnostic: input.toolState.agentDiagnostic
155127
155170
  }) : null;
155128
- if (rendered && input.toolState.progressComment) {
155129
- await reportErrorToComment({ toolState: input.toolState, error: rendered.comment }).catch(
155130
- (error49) => {
155131
- log.debug(`failure error report failed: ${error49}`);
155132
- }
155133
- );
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
+ });
155134
155179
  }
155135
155180
  if (input.result.success && input.toolState.progressComment && !input.toolState.finalSummaryWritten) {
155136
155181
  await deleteProgressComment(input.toolContext).catch((error49) => {
@@ -155162,7 +155207,11 @@ async function writeRunErrorOutputs(input) {
155162
155207
  } catch {
155163
155208
  }
155164
155209
  try {
155165
- 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
+ });
155166
155215
  } catch {
155167
155216
  }
155168
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.13",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "pullfrog": "dist/cli.mjs",