pullfrog 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -19718,10 +19718,10 @@ var require_core = __commonJS({
19718
19718
  (0, command_1.issueCommand)("set-env", { name }, convertedVal);
19719
19719
  }
19720
19720
  exports.exportVariable = exportVariable;
19721
- function setSecret5(secret) {
19721
+ function setSecret6(secret) {
19722
19722
  (0, command_1.issueCommand)("add-mask", {}, secret);
19723
19723
  }
19724
- exports.setSecret = setSecret5;
19724
+ exports.setSecret = setSecret6;
19725
19725
  function addPath(inputPath) {
19726
19726
  const filePath = process.env["GITHUB_PATH"] || "";
19727
19727
  if (filePath) {
@@ -19838,12 +19838,12 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``);
19838
19838
  return process.env[`STATE_${name}`] || "";
19839
19839
  }
19840
19840
  exports.getState = getState;
19841
- function getIDToken4(aud) {
19841
+ function getIDToken3(aud) {
19842
19842
  return __awaiter(this, void 0, void 0, function* () {
19843
19843
  return yield oidc_utils_1.OidcClient.getIDToken(aud);
19844
19844
  });
19845
19845
  }
19846
- exports.getIDToken = getIDToken4;
19846
+ exports.getIDToken = getIDToken3;
19847
19847
  var summary_1 = require_summary();
19848
19848
  Object.defineProperty(exports, "summary", { enumerable: true, get: function() {
19849
19849
  return summary_1.summary;
@@ -93492,14 +93492,14 @@ var require_turndown_cjs = __commonJS({
93492
93492
  } else if (node2.nodeType === 1) {
93493
93493
  replacement = replacementForNode.call(self2, node2);
93494
93494
  }
93495
- return join22(output, replacement);
93495
+ return join24(output, replacement);
93496
93496
  }, "");
93497
93497
  }
93498
93498
  function postProcess(output) {
93499
93499
  var self2 = this;
93500
93500
  this.rules.forEach(function(rule) {
93501
93501
  if (typeof rule.append === "function") {
93502
- output = join22(output, rule.append(self2.options));
93502
+ output = join24(output, rule.append(self2.options));
93503
93503
  }
93504
93504
  });
93505
93505
  return output.replace(/^[\t\r\n]+/, "").replace(/[\t\r\n\s]+$/, "");
@@ -93511,7 +93511,7 @@ var require_turndown_cjs = __commonJS({
93511
93511
  if (whitespace.leading || whitespace.trailing) content = content.trim();
93512
93512
  return whitespace.leading + rule.replacement(content, node2, this.options) + whitespace.trailing;
93513
93513
  }
93514
- function join22(output, replacement) {
93514
+ function join24(output, replacement) {
93515
93515
  var s1 = trimTrailingNewlines(output);
93516
93516
  var s2 = trimLeadingNewlines(replacement);
93517
93517
  var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
@@ -98926,7 +98926,7 @@ var require_fast_content_type_parse = __commonJS({
98926
98926
  // main.ts
98927
98927
  import { existsSync as existsSync8, readdirSync as readdirSync2 } from "node:fs";
98928
98928
  import { readFile as readFile5 } from "node:fs/promises";
98929
- import { join as join21 } from "node:path";
98929
+ import { join as join23 } from "node:path";
98930
98930
 
98931
98931
  // agents/claude.ts
98932
98932
  import { execFileSync as execFileSync2 } from "node:child_process";
@@ -98943,11 +98943,20 @@ var providers = {
98943
98943
  displayName: "Anthropic",
98944
98944
  envVars: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
98945
98945
  models: {
98946
+ // OpenRouter serves claude-fable-5, but models.dev's OpenRouter mirror
98947
+ // hasn't indexed it yet (shipped 2026-06-09), so the catalog drift gate
98948
+ // can't validate an openRouterResolve. omit it until the mirror catches
98949
+ // up; direct BYOK / Claude Code resolves anthropic/claude-fable-5 fine.
98950
+ "claude-fable": {
98951
+ displayName: "Claude Fable",
98952
+ resolve: "anthropic/claude-fable-5",
98953
+ preferred: true,
98954
+ subagentModel: "claude-sonnet"
98955
+ },
98946
98956
  "claude-opus": {
98947
98957
  displayName: "Claude Opus",
98948
98958
  resolve: "anthropic/claude-opus-4-8",
98949
98959
  openRouterResolve: "openrouter/anthropic/claude-opus-4.8",
98950
- preferred: true,
98951
98960
  subagentModel: "claude-sonnet"
98952
98961
  },
98953
98962
  "claude-sonnet": {
@@ -99013,7 +99022,8 @@ var providers = {
99013
99022
  },
99014
99023
  o3: {
99015
99024
  displayName: "O3",
99016
- resolve: "openai/o3"
99025
+ resolve: "openai/o3",
99026
+ openRouterResolve: "openrouter/openai/o3"
99017
99027
  }
99018
99028
  }
99019
99029
  }),
@@ -99383,6 +99393,18 @@ function parseModel(slug2) {
99383
99393
  function getModelProvider(slug2) {
99384
99394
  return parseModel(slug2).provider;
99385
99395
  }
99396
+ function getModelEnvVars(slug2) {
99397
+ const parsed2 = parseModel(slug2);
99398
+ const providerConfig = providers[parsed2.provider];
99399
+ if (!providerConfig) {
99400
+ return [];
99401
+ }
99402
+ const modelConfig = providerConfig.models[parsed2.model];
99403
+ if (modelConfig?.envVars) {
99404
+ return modelConfig.envVars.slice();
99405
+ }
99406
+ return providerConfig.envVars.slice();
99407
+ }
99386
99408
  var modelAliases = Object.entries(providers).flatMap(
99387
99409
  ([providerKey, config3]) => Object.entries(config3.models).map(([modelId, def]) => ({
99388
99410
  slug: `${providerKey}/${modelId}`,
@@ -99407,6 +99429,9 @@ if (!defaultProxyAlias?.openRouterResolve) {
99407
99429
  }
99408
99430
  var DEFAULT_PROXY_MODEL = defaultProxyAlias.openRouterResolve;
99409
99431
  var defaultProxyDisplayName = defaultProxyAlias.displayName;
99432
+ function resolveModelSlug(slug2) {
99433
+ return modelAliases.find((a) => a.slug === slug2)?.resolve;
99434
+ }
99410
99435
  var MAX_FALLBACK_DEPTH = 10;
99411
99436
  function resolveDisplayAlias(slug2) {
99412
99437
  let current = slug2;
@@ -99554,6 +99579,51 @@ function createProcessOutputActivityTimeout(ctx) {
99554
99579
  };
99555
99580
  }
99556
99581
 
99582
+ // utils/claudeSubscription.ts
99583
+ var CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
99584
+ var fallbackResolve = resolveModelSlug("anthropic/claude-haiku");
99585
+ if (!fallbackResolve) {
99586
+ throw new Error("claudeSubscription preflight: anthropic/claude-haiku missing from registry");
99587
+ }
99588
+ var FALLBACK_PROBE_MODEL = fallbackResolve.slice(fallbackResolve.indexOf("/") + 1);
99589
+ async function preflightClaudeSubscription(params) {
99590
+ let res;
99591
+ try {
99592
+ res = await fetch("https://api.anthropic.com/v1/messages", {
99593
+ method: "POST",
99594
+ headers: {
99595
+ authorization: `Bearer ${params.token}`,
99596
+ "anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
99597
+ "anthropic-version": "2023-06-01",
99598
+ "content-type": "application/json",
99599
+ "x-app": "cli"
99600
+ },
99601
+ body: JSON.stringify({
99602
+ model: params.model ?? FALLBACK_PROBE_MODEL,
99603
+ max_tokens: 1,
99604
+ system: CLAUDE_CODE_IDENTITY,
99605
+ messages: [{ role: "user", content: "ok" }]
99606
+ }),
99607
+ signal: AbortSignal.timeout(1e4)
99608
+ });
99609
+ } catch {
99610
+ return { usable: true };
99611
+ }
99612
+ if (res.status !== 401 && res.status !== 429) return { usable: true };
99613
+ const body = await res.text().catch(() => "");
99614
+ return { usable: false, reason: `${res.status}: ${extractApiErrorMessage(body)}` };
99615
+ }
99616
+ function extractApiErrorMessage(body) {
99617
+ try {
99618
+ const parsed2 = JSON.parse(body);
99619
+ if (typeof parsed2 === "object" && parsed2 !== null && "error" in parsed2 && typeof parsed2.error === "object" && parsed2.error !== null && "message" in parsed2.error && typeof parsed2.error.message === "string") {
99620
+ return parsed2.error.message;
99621
+ }
99622
+ } catch {
99623
+ }
99624
+ return body.slice(0, 200);
99625
+ }
99626
+
99557
99627
  // utils/log.ts
99558
99628
  var core = __toESM(require_core(), 1);
99559
99629
  var import_table = __toESM(require_src(), 1);
@@ -100067,7 +100137,7 @@ var import_semver = __toESM(require_semver2(), 1);
100067
100137
  // package.json
100068
100138
  var package_default = {
100069
100139
  name: "pullfrog",
100070
- version: "0.1.28",
100140
+ version: "0.1.30",
100071
100141
  type: "module",
100072
100142
  bin: {
100073
100143
  pullfrog: "dist/cli.mjs",
@@ -100642,10 +100712,13 @@ function resolveVertexOpenCodeModel(model) {
100642
100712
  var SUBAGENT_DENIED_TOOLS = [
100643
100713
  // working-tree mutation: switches HEAD onto pr-N and registers a push remote
100644
100714
  "checkout_pr",
100645
- // remote mutation: pushes commits / branches / tags / deletes a branch
100715
+ // remote mutation: pushes commits / branches / tags / deletes a branch.
100716
+ // commit_changes lands the orchestrator's (possibly half-finished) shared
100717
+ // working tree directly on the remote — strictly worse than push_branch.
100646
100718
  "push_branch",
100647
100719
  "push_tags",
100648
100720
  "delete_branch",
100721
+ "commit_changes",
100649
100722
  // GitHub PR state mutation
100650
100723
  "create_pull_request",
100651
100724
  "update_pull_request_body",
@@ -100910,8 +100983,10 @@ Inline comments use the same severity framing as body \`### \` sections, scaled
100910
100983
  - **Don't repeat diff content**, don't include raw \`+123 / -45\` stats, don't include a changelog section, don't use horizontal rules (\`---\`).
100911
100984
  - **Pull file/commit counts from \`checkout_pr\` metadata** \u2014 never count manually.
100912
100985
  - **Legacy headings REMOVED.** Do not use \`### Key changes\`, \`### Issues found\`, \`<b>TL;DR</b>\`, or \`<sub><b>Summary</b>\`. The new structure subsumes them.`;
100913
- function computeModes(agentId) {
100986
+ function computeModes(agentId, signedCommits = false) {
100914
100987
  const t = (toolName) => formatMcpToolRef(agentId, toolName);
100988
+ const commitStep = signedCommits ? `commit via \`${t("commit_changes")}\` \u2014 it lands a GitHub-signed commit directly on the remote branch (no push step)` : `commit locally via shell (\`git add . && git commit -m "..."\`)`;
100989
+ const finalizeStep = signedCommits ? `confirm a clean working tree (\`git status\`) \u2014 your \`${t("commit_changes")}\` calls already landed the work on the remote` : `confirm a clean working tree, then push via \`${t("push_branch")}\``;
100915
100990
  return [
100916
100991
  {
100917
100992
  name: "Build",
@@ -100982,10 +101057,10 @@ function computeModes(agentId) {
100982
101057
  - Do NOT defect-hunt the diff yourself in parallel with the subagent. Your role is dispatch + evaluation; doing the review yourself reintroduces the implementation bias the subagent is meant to mitigate.
100983
101058
  - For diffs that rely on third-party API contracts, SDK semantics, framework directives, or DB engine specifics, instruct the subagent to verify load-bearing claims via web search and quote source URLs rather than trust training data \u2014 this is the single most common review-quality failure mode.
100984
101059
 
100985
- Be **discerning** about what comes back. The reviewer is an AI subagent and is fallible \u2014 treat every finding as a hypothesis, not a directive, and **verify each one yourself** against the diff and the code before deciding whether to apply. You are searching for a solution that is **complete, minimal, and elegant** \u2014 you may need to think hard to find it. Do not over-engineer, do not be over-defensive, **do not write AI slop**. Reviewers bias toward *recommending additions*, and that bias has a recognizable slop texture: defensive checks for cases that cannot happen, extra logging, new abstractions used once, comments restating code, tests asserting tautologies, "just-in-case" guards, error handlers for cases the type system already rules out. Reject those. For each surviving finding, ask: would applying it leave the code more sound, correct, AND elegant? Two-out-of-three means look harder for a fix that gets all three before settling. After applying the fixes you accept, re-read your diff and be discerning about what *you just changed*: if any fix turned out to be bloat in context, revert it. Then verify only intended changes are present, no debug artifacts or commented-out code remain, no unrelated files were modified. Commit locally via shell (\`git add . && git commit -m "..."\`).
101060
+ Be **discerning** about what comes back. The reviewer is an AI subagent and is fallible \u2014 treat every finding as a hypothesis, not a directive, and **verify each one yourself** against the diff and the code before deciding whether to apply. You are searching for a solution that is **complete, minimal, and elegant** \u2014 you may need to think hard to find it. Do not over-engineer, do not be over-defensive, **do not write AI slop**. Reviewers bias toward *recommending additions*, and that bias has a recognizable slop texture: defensive checks for cases that cannot happen, extra logging, new abstractions used once, comments restating code, tests asserting tautologies, "just-in-case" guards, error handlers for cases the type system already rules out. Reject those. For each surviving finding, ask: would applying it leave the code more sound, correct, AND elegant? Two-out-of-three means look harder for a fix that gets all three before settling. After applying the fixes you accept, re-read your diff and be discerning about what *you just changed*: if any fix turned out to be bloat in context, revert it. Then verify only intended changes are present, no debug artifacts or commented-out code remain, no unrelated files were modified. Then ${commitStep}.
100986
101061
 
100987
101062
  6. **finalize**:
100988
- - confirm a clean working tree, then push via \`${t("push_branch")}\` (see *SYSTEM* Git rules if this fails \u2014 prepush errors are usually the repo's tests/lint, not infra timeouts)
101063
+ - ${finalizeStep} (see *SYSTEM* Git rules if this fails \u2014 prepush errors are usually the repo's tests/lint, not infra timeouts)
100989
101064
  - create a PR via \`${t("create_pull_request")}\`
100990
101065
  - call \`${t("report_progress")}\` with the PR link or the exact error if push/PR failed
100991
101066
 
@@ -101013,12 +101088,12 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
101013
101088
 
101014
101089
  5. Quality check:
101015
101090
  - test changes, then review the diff before committing \u2014 verify only intended changes are present, no debug artifacts remain, no fix turned out to be bloat in context (revert any that did), and the changes are clean enough that a senior engineer would approve without hesitation
101016
- - commit locally via shell (\`git add . && git commit -m "..."\`)
101091
+ - ${commitStep}
101017
101092
 
101018
101093
  6. Finalize. Reply + resolve are paired write actions: do BOTH or NEITHER for each thread.
101019
- - confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
101020
- - **if push fails**, call \`${t("report_progress")}\` with the exact error and STOP \u2014 do NOT reply or resolve any thread until the fix is live on the remote. Resolving a thread without the fix landing misleads the reviewer.
101021
- - **on push success**, for each thread you acted on:
101094
+ - ${finalizeStep} (same push/prepush guidance as Build mode in *SYSTEM*)
101095
+ - **if the push/commit fails**, call \`${t("report_progress")}\` with the exact error and STOP \u2014 do NOT reply or resolve any thread until the fix is live on the remote. Resolving a thread without the fix landing misleads the reviewer.
101096
+ - **once the fix is live on the remote**, for each thread you acted on:
101022
101097
  - reply ONCE via \`${t("reply_to_review_comment")}\`. The \`comment_id\` parameter takes the root comment's numeric \`id=\` (from the first \`comment author=...\` tag in the \`${t("get_review_comments")}\` output) \u2014 NOT the \`thread=\` value; that's a separate GraphQL ID used by resolve. The runtime dedupes identical bodies within a session.
101023
101098
  - **immediately** call \`${t("resolve_review_thread")}\` with that thread's \`thread=\` value as \`thread_id\`. Resolve every thread where you (a) made the requested code change in full \u2014 partial fixes leave the thread open \u2014 OR (b) replied with a substantive answer the user explicitly asked for. Do NOT resolve threads where you pushed back on the request and the disagreement is unresolved; leave those open for the human to mediate.
101024
101099
  - call \`${t("report_progress")}\` with a brief summary`
@@ -101293,10 +101368,10 @@ ${PR_SUMMARY_FORMAT}`
101293
101368
  - fix the issue using your native file and shell tools
101294
101369
  - verify the fix by re-running the exact CI command
101295
101370
  - review the diff before committing \u2014 verify only the fix is present, no debug artifacts, no unrelated changes. the fix should be clean enough that a senior engineer would approve without hesitation.
101296
- - commit locally via shell (\`git add . && git commit -m "..."\`)
101371
+ - ${commitStep}
101297
101372
 
101298
101373
  6. Finalize:
101299
- - confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
101374
+ - ${finalizeStep} (same push/prepush guidance as Build mode in *SYSTEM*)
101300
101375
  - call \`${t("report_progress")}\` with the diagnosis and fix summary (or the exact push error if push failed)`
101301
101376
  },
101302
101377
  {
@@ -101312,8 +101387,8 @@ ${PR_SUMMARY_FORMAT}`
101312
101387
  - Call \`${t("git_fetch")}\` to fetch the base branch.
101313
101388
 
101314
101389
  3. **Merge Attempt**:
101315
- - Run \`git merge origin/<base_branch>\` via shell.
101316
- - If it succeeds automatically, confirm a clean working tree, push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*), and call \`${t("report_progress")}\` with a brief success note or the exact push error if push failed \u2014 **then stop; do not run steps 4\u20135.**
101390
+ - Run \`git merge ${signedCommits ? "--no-commit " : ""}origin/<base_branch>\` via shell.
101391
+ - If it succeeds automatically, ${signedCommits ? `conclude it via \`${t("commit_changes")}\` (it turns the pending merge into a signed merge commit on the remote)` : `confirm a clean working tree, push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)`}, and call \`${t("report_progress")}\` with a brief success note or the exact error if it failed \u2014 **then stop; do not run steps 4\u20135.**
101317
101392
  - If it fails (conflicts), resolve them manually (continue to steps 4\u20135).
101318
101393
 
101319
101394
  4. **Resolve Conflicts**:
@@ -101323,8 +101398,8 @@ ${PR_SUMMARY_FORMAT}`
101323
101398
 
101324
101399
  5. **Finalize**:
101325
101400
  - Run a final verification (build/test) to ensure the resolution works.
101326
- - \`git add . && git commit -m "resolve merge conflicts"\`
101327
- - confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
101401
+ - ${signedCommits ? `\`git add .\`, then conclude via \`${t("commit_changes")}\` with message "resolve merge conflicts"` : `\`git add . && git commit -m "resolve merge conflicts"\``}
101402
+ - ${finalizeStep} (same push/prepush guidance as Build mode in *SYSTEM*)
101328
101403
  - Call \`${t("report_progress")}\` with a summary of what was resolved (or the exact push error if push failed)`
101329
101404
  },
101330
101405
  {
@@ -101343,7 +101418,7 @@ ${PR_SUMMARY_FORMAT}`
101343
101418
  - if code changes are needed: review your own diff before committing \u2014 verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation
101344
101419
 
101345
101420
  4. Finalize:
101346
- - if code changes were made, push to a pull request (new or existing) using \`${t("push_branch")}\` and \`${t("create_pull_request")}\` as needed. \`git status\` must be clean before you finish (see *SYSTEM* Git rules if push fails).
101421
+ - if code changes were made, get them onto a pull request (new or existing) using ${signedCommits ? `\`${t("commit_changes")}\`` : `\`${t("push_branch")}\``} and \`${t("create_pull_request")}\` as needed. \`git status\` must be clean before you finish (see *SYSTEM* Git rules if this fails).
101347
101422
  - call \`${t("report_progress")}\` once with results \u2014 include exact tool errors if push or PR creation failed
101348
101423
  - if the task involved labeling, commenting, or other GitHub operations, perform those directly`
101349
101424
  }
@@ -102323,10 +102398,21 @@ var claude = agent({
102323
102398
  env2.ANTHROPIC_MODEL = specifier;
102324
102399
  }
102325
102400
  if (env2.CLAUDE_CODE_OAUTH_TOKEN && !isBedrockRoute && env2.ANTHROPIC_API_KEY) {
102326
- log.debug(
102327
- "\xBB CLAUDE_CODE_OAUTH_TOKEN present \u2014 stripping ANTHROPIC_API_KEY from Claude Code env so the OAuth subscription is used"
102328
- );
102329
- delete env2.ANTHROPIC_API_KEY;
102401
+ const preflight = await preflightClaudeSubscription({
102402
+ token: env2.CLAUDE_CODE_OAUTH_TOKEN,
102403
+ model
102404
+ });
102405
+ if (preflight.usable) {
102406
+ log.debug(
102407
+ "\xBB CLAUDE_CODE_OAUTH_TOKEN present \u2014 stripping ANTHROPIC_API_KEY from Claude Code env so the OAuth subscription is used"
102408
+ );
102409
+ delete env2.ANTHROPIC_API_KEY;
102410
+ } else {
102411
+ log.info(
102412
+ `\xBB Claude subscription unusable (${preflight.reason}) \u2014 falling back to ANTHROPIC_API_KEY`
102413
+ );
102414
+ delete env2.CLAUDE_CODE_OAUTH_TOKEN;
102415
+ }
102330
102416
  }
102331
102417
  log.info(`\xBB effort: ${effort}`);
102332
102418
  log.debug(`\xBB starting Pullfrog (Claude Code): ${cliPath} ${baseArgs.join(" ")}`);
@@ -115386,15 +115472,6 @@ function isLocalApiUrl() {
115386
115472
  // utils/buildPullfrogFooter.ts
115387
115473
  var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
115388
115474
  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>`;
115389
- function providerDisplayName(slug2) {
115390
- try {
115391
- const key = getModelProvider(slug2);
115392
- const meta3 = providers[key];
115393
- return meta3?.displayName ?? key;
115394
- } catch {
115395
- return slug2;
115396
- }
115397
- }
115398
115475
  function formatModelLabel(params) {
115399
115476
  const alias = resolveDisplayAlias(params.model) ?? // reverse-lookup: when the caller passes an effective model (proxy or
115400
115477
  // resolved target like "openrouter/anthropic/claude-opus-4.7") instead of
@@ -115405,9 +115482,7 @@ function formatModelLabel(params) {
115405
115482
  if (params.oss) {
115406
115483
  return `\`${displayName}\` (free via [Pullfrog for OSS](https://pullfrog.com/for-oss))`;
115407
115484
  }
115408
- const base = alias?.isFree ? `\`${displayName}\` (free)` : `\`${displayName}\``;
115409
- if (!params.fallbackFrom) return base;
115410
- return `${base} (credentials for ${providerDisplayName(params.fallbackFrom)} not configured)`;
115485
+ return alias?.isFree ? `\`${displayName}\` (free)` : `\`${displayName}\``;
115411
115486
  }
115412
115487
  function buildPullfrogFooter(params) {
115413
115488
  const parts = [];
@@ -115425,9 +115500,7 @@ function buildPullfrogFooter(params) {
115425
115500
  parts.push("via [Pullfrog](https://pullfrog.com)");
115426
115501
  }
115427
115502
  if (params.model) {
115428
- parts.push(
115429
- `Using ${formatModelLabel({ model: params.model, fallbackFrom: params.fallbackFrom, oss: params.oss })}`
115430
- );
115503
+ parts.push(`Using ${formatModelLabel({ model: params.model, oss: params.oss })}`);
115431
115504
  }
115432
115505
  const allParts = [...parts, "[\u{1D54F}](https://x.com/pullfrogai)"];
115433
115506
  return `
@@ -116225,7 +116298,6 @@ function buildCommentFooter(ctx, customParts) {
116225
116298
  } : void 0,
116226
116299
  customParts,
116227
116300
  model: ctx.toolState.model,
116228
- fallbackFrom: ctx.toolState.modelFallback?.from,
116229
116301
  oss: ctx.oss
116230
116302
  });
116231
116303
  }
@@ -117299,24 +117371,60 @@ var installPythonDependencies = {
117299
117371
 
117300
117372
  // prep/index.ts
117301
117373
  var prepSteps = [installNodeDependencies, installPythonDependencies];
117374
+ async function dirtyTrackedPaths() {
117375
+ const result = await spawn({
117376
+ cmd: "git",
117377
+ args: ["diff", "--name-only", "HEAD"],
117378
+ env: process.env,
117379
+ activityTimeout: 0
117380
+ });
117381
+ if (result.exitCode !== 0) {
117382
+ throw new Error(
117383
+ `git diff --name-only HEAD failed (exit ${result.exitCode}): ${result.stderr.trim() || "(no stderr)"}`
117384
+ );
117385
+ }
117386
+ return new Set(result.stdout.split("\n").filter(Boolean));
117387
+ }
117388
+ async function restorePrepDirtiedFiles(preDirty) {
117389
+ const dirtied = [...await dirtyTrackedPaths()].filter((path4) => !preDirty.has(path4));
117390
+ if (dirtied.length === 0) return;
117391
+ const result = await spawn({
117392
+ cmd: "git",
117393
+ args: ["restore", "--staged", "--worktree", "--", ...dirtied],
117394
+ env: process.env,
117395
+ activityTimeout: 0
117396
+ });
117397
+ if (result.exitCode !== 0) {
117398
+ log.warning(
117399
+ `\xBB failed to restore ${dirtied.length} tracked file(s) modified by prep: ${result.stderr.trim() || "(no stderr)"}`
117400
+ );
117401
+ return;
117402
+ }
117403
+ log.info(`\xBB restored ${dirtied.length} tracked file(s) modified by prep: ${dirtied.join(", ")}`);
117404
+ }
117302
117405
  async function runPrepPhase(options) {
117303
117406
  log.debug("\xBB starting prep phase...");
117304
117407
  const startTime = performance7.now();
117305
117408
  const results = [];
117306
- for (const step of prepSteps) {
117307
- const shouldRun = await step.shouldRun();
117308
- if (!shouldRun) {
117309
- log.debug(`\xBB skipping ${step.name} (not applicable)`);
117310
- continue;
117311
- }
117312
- log.debug(`\xBB running ${step.name}...`);
117313
- const result = await step.run(options);
117314
- results.push(result);
117315
- if (result.dependenciesInstalled) {
117316
- log.debug(`\xBB ${step.name}: dependencies installed`);
117317
- } else if (result.issues.length > 0) {
117318
- log.warning(`\xBB ${step.name}: ${result.issues[0]}`);
117409
+ const preDirty = await dirtyTrackedPaths();
117410
+ try {
117411
+ for (const step of prepSteps) {
117412
+ const shouldRun = await step.shouldRun();
117413
+ if (!shouldRun) {
117414
+ log.debug(`\xBB skipping ${step.name} (not applicable)`);
117415
+ continue;
117416
+ }
117417
+ log.debug(`\xBB running ${step.name}...`);
117418
+ const result = await step.run(options);
117419
+ results.push(result);
117420
+ if (result.dependenciesInstalled) {
117421
+ log.debug(`\xBB ${step.name}: dependencies installed`);
117422
+ } else if (result.issues.length > 0) {
117423
+ log.warning(`\xBB ${step.name}: ${result.issues[0]}`);
117424
+ }
117319
117425
  }
117426
+ } finally {
117427
+ await restorePrepDirtiedFiles(preDirty);
117320
117428
  }
117321
117429
  const totalDurationMs = performance7.now() - startTime;
117322
117430
  log.debug(`\xBB prep phase completed (${Math.round(totalDurationMs)}ms)`);
@@ -149499,7 +149607,7 @@ function closeBrowserDaemon(toolState) {
149499
149607
  // mcp/checkout.ts
149500
149608
  import { createHash as createHash2 } from "node:crypto";
149501
149609
  import { statSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync7 } from "node:fs";
149502
- import { join as join12 } from "node:path";
149610
+ import { join as join14 } from "node:path";
149503
149611
 
149504
149612
  // utils/diffCoverage.ts
149505
149613
  import { isAbsolute, normalize as normalize2, resolve } from "node:path";
@@ -149769,6 +149877,7 @@ function readNumber(params) {
149769
149877
  import { execSync } from "node:child_process";
149770
149878
  import { createHash } from "node:crypto";
149771
149879
  import { readFileSync as readFileSync2, realpathSync, unlinkSync } from "node:fs";
149880
+ import { join as join10 } from "node:path";
149772
149881
 
149773
149882
  // utils/shell.ts
149774
149883
  import { spawnSync as spawnSync4 } from "node:child_process";
@@ -149838,6 +149947,18 @@ function verifyGitBinary() {
149838
149947
  }
149839
149948
  return gitBinary.path;
149840
149949
  }
149950
+ var hooksDirCache = /* @__PURE__ */ new Map();
149951
+ function resolveHooksDir(cwd, gitPath) {
149952
+ const cached4 = hooksDirCache.get(cwd);
149953
+ if (cached4) return cached4;
149954
+ const commonDir = $(gitPath, ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
149955
+ cwd,
149956
+ log: false
149957
+ }).trim();
149958
+ const hooksDir = join10(commonDir, "hooks");
149959
+ hooksDirCache.set(cwd, hooksDir);
149960
+ return hooksDir;
149961
+ }
149841
149962
  var authServer;
149842
149963
  function setGitAuthServer(server) {
149843
149964
  authServer = server;
@@ -149859,6 +149980,8 @@ async function $git(subcommand, args2, options) {
149859
149980
  "protocol.file.allow=never",
149860
149981
  "-c",
149861
149982
  "core.sshCommand=ssh",
149983
+ "-c",
149984
+ `core.hooksPath=${resolveHooksDir(cwd, gitPath)}`,
149862
149985
  subcommand,
149863
149986
  ...args2
149864
149987
  ];
@@ -150128,7 +150251,274 @@ function postProcessRangeDiff(raw2, contextLines = 3) {
150128
150251
  // mcp/git.ts
150129
150252
  import { randomUUID as randomUUID3 } from "node:crypto";
150130
150253
  import { writeFileSync as writeFileSync6 } from "node:fs";
150131
- import { join as join10 } from "node:path";
150254
+ import { join as join12 } from "node:path";
150255
+
150256
+ // utils/apiCommit.ts
150257
+ import { execFileSync as execFileSync6 } from "node:child_process";
150258
+ import { lstat, readlink } from "node:fs/promises";
150259
+ import { join as join11 } from "node:path";
150260
+ var GITHUB_API = "https://api.github.com";
150261
+ var MAX_BLOB_BYTES = 30 * 1024 * 1024;
150262
+ var BLOB_UPLOAD_CONCURRENCY = 8;
150263
+ function getRepoRoot() {
150264
+ return $("git", ["rev-parse", "--show-toplevel"], { log: false }).trim();
150265
+ }
150266
+ async function gh(params) {
150267
+ const response = await fetch(`${GITHUB_API}${params.path}`, {
150268
+ method: params.method,
150269
+ headers: {
150270
+ Accept: "application/vnd.github+json",
150271
+ Authorization: `Bearer ${params.token}`,
150272
+ "X-GitHub-Api-Version": "2022-11-28",
150273
+ "Content-Type": "application/json"
150274
+ },
150275
+ body: params.body === void 0 ? null : JSON.stringify(params.body)
150276
+ });
150277
+ const text = await response.text();
150278
+ let json4;
150279
+ try {
150280
+ json4 = text ? JSON.parse(text) : null;
150281
+ } catch {
150282
+ json4 = text.slice(0, 500);
150283
+ }
150284
+ return { status: response.status, json: json4 };
150285
+ }
150286
+ function ghError(method, path4, result) {
150287
+ return new Error(`${method} ${path4} failed (${result.status}): ${JSON.stringify(result.json)}`);
150288
+ }
150289
+ function isRetryable(status) {
150290
+ return status === 403 || status === 429 || status >= 500;
150291
+ }
150292
+ function detectWorkingTreeChanges() {
150293
+ const byPath = /* @__PURE__ */ new Map();
150294
+ const diff = $("git", ["diff", "--name-status", "--no-renames", "-z", "HEAD"], { log: false });
150295
+ const tokens = diff.split("\0").filter((t) => t.length > 0);
150296
+ for (let i = 0; i + 1 < tokens.length; i += 2) {
150297
+ const status = tokens[i];
150298
+ const path4 = tokens[i + 1];
150299
+ if (status === void 0 || path4 === void 0) break;
150300
+ if (status === "U") {
150301
+ throw new Error(
150302
+ `'${path4}' has unresolved merge conflicts. resolve the conflicts, stage the result with git add, then retry.`
150303
+ );
150304
+ }
150305
+ byPath.set(path4, { path: path4, deleted: status === "D" });
150306
+ }
150307
+ const porcelain = $("git", ["status", "--porcelain=v1", "-z", "-uall", "--no-renames"], {
150308
+ log: false
150309
+ });
150310
+ for (const entry of porcelain.split("\0")) {
150311
+ if (entry.startsWith("?? ") && !byPath.has(entry.slice(3))) {
150312
+ const path4 = entry.slice(3);
150313
+ byPath.set(path4, { path: path4, deleted: false });
150314
+ }
150315
+ }
150316
+ return [...byPath.values()];
150317
+ }
150318
+ async function assertApiCommittable(files) {
150319
+ const present = files.filter((f) => !f.deleted).map((f) => f.path);
150320
+ if (present.length === 0) return;
150321
+ const root = getRepoRoot();
150322
+ const attrs = $(
150323
+ "git",
150324
+ ["check-attr", "filter", "-z", "--", ...present.map((p) => join11(root, p))],
150325
+ { log: false }
150326
+ );
150327
+ const parts = attrs.split("\0");
150328
+ for (let i = 0; i + 2 < parts.length; i += 3) {
150329
+ if (parts[i + 2] === "lfs") {
150330
+ throw new Error(
150331
+ `'${parts[i]}' is tracked by git-lfs, which signed commits can't upload. remove it from the change set or ask the user to disable signed commits for this repo.`
150332
+ );
150333
+ }
150334
+ }
150335
+ for (const path4 of present) {
150336
+ const stat = await lstat(join11(root, path4));
150337
+ if (stat.isDirectory()) {
150338
+ throw new Error(
150339
+ `'${path4}' is a directory (nested repository or submodule?) \u2014 signed commits only support files and symlinks.`
150340
+ );
150341
+ }
150342
+ }
150343
+ }
150344
+ async function createBlobEntry(params) {
150345
+ const absPath = join11(params.repoRoot, params.path);
150346
+ const stat = await lstat(absPath);
150347
+ if (stat.size > MAX_BLOB_BYTES) {
150348
+ throw new Error(
150349
+ `'${params.path}' is ${Math.round(stat.size / 1024 / 1024)}MB \u2014 too large for signed commits (the GitHub blob API rejects large uploads). use git-lfs for large assets or ask the user to disable signed commits for this repo.`
150350
+ );
150351
+ }
150352
+ let mode;
150353
+ let content;
150354
+ if (stat.isSymbolicLink()) {
150355
+ mode = "120000";
150356
+ content = await readlink(absPath, { encoding: "buffer" });
150357
+ } else {
150358
+ mode = stat.mode & 64 ? "100755" : "100644";
150359
+ const cleanSha = $("git", ["hash-object", "-w", "--", absPath], { log: false }).trim();
150360
+ content = execFileSync6("git", ["cat-file", "blob", cleanSha], {
150361
+ cwd: params.repoRoot,
150362
+ maxBuffer: 2 * MAX_BLOB_BYTES
150363
+ });
150364
+ }
150365
+ const path4 = `${params.repoPath}/git/blobs`;
150366
+ let result;
150367
+ for (const delayMs of [0, 1e3, 3e3]) {
150368
+ if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
150369
+ result = await gh({
150370
+ token: params.token,
150371
+ method: "POST",
150372
+ path: path4,
150373
+ body: { content: content.toString("base64"), encoding: "base64" }
150374
+ });
150375
+ if (result.status === 201) {
150376
+ const blob = result.json;
150377
+ return { path: params.path, mode, type: "blob", sha: blob.sha };
150378
+ }
150379
+ if (!isRetryable(result.status)) break;
150380
+ log.info(`blob upload for ${params.path} got ${result.status}, retrying`);
150381
+ }
150382
+ if (!result) throw new Error(`POST ${path4} failed`);
150383
+ throw ghError("POST", path4, result);
150384
+ }
150385
+ async function updateRefWithRetry(params) {
150386
+ const path4 = `${params.repoPath}/git/refs/heads/${encodeBranchPath(params.remoteBranch)}`;
150387
+ let lastResult;
150388
+ for (const delayMs of [0, 1e3, 3e3]) {
150389
+ if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
150390
+ const result = await gh({
150391
+ token: params.token,
150392
+ method: "PATCH",
150393
+ path: path4,
150394
+ body: { sha: params.sha, force: false }
150395
+ });
150396
+ if (result.status === 200) return;
150397
+ if (result.status === 422) {
150398
+ const detail = JSON.stringify(result.json);
150399
+ if (/fast.forward/i.test(detail)) {
150400
+ throw new Error(
150401
+ `the remote branch '${params.remoteBranch}' moved while committing (concurrent push). fetch it with git_fetch, integrate with git merge --no-commit origin/${params.remoteBranch}, resolve any conflicts, git add the results, then retry commit_changes.`
150402
+ );
150403
+ }
150404
+ throw ghError("PATCH", path4, result);
150405
+ }
150406
+ lastResult = result;
150407
+ if (!isRetryable(result.status)) break;
150408
+ log.info(`ref update got ${result.status}, retrying`);
150409
+ }
150410
+ if (lastResult) throw ghError("PATCH", path4, lastResult);
150411
+ throw new Error(`PATCH ${path4} failed`);
150412
+ }
150413
+ function encodeBranchPath(branch) {
150414
+ return branch.split("/").map(encodeURIComponent).join("/");
150415
+ }
150416
+ function validateRemoteBranch(branch) {
150417
+ const bad = branch.startsWith("-") || branch.startsWith("/") || branch.endsWith("/") || branch.includes("..") || branch.includes("@{") || /[\s~^:?*[\]\\]/.test(branch);
150418
+ if (bad) throw new Error(`invalid remote branch name '${branch}'`);
150419
+ }
150420
+ async function createSignedCommit(params) {
150421
+ validateRemoteBranch(params.remoteBranch);
150422
+ const repoPath = `/repos/${params.owner}/${params.repo}`;
150423
+ const branchPath = `${repoPath}/git/ref/heads/${encodeBranchPath(params.remoteBranch)}`;
150424
+ const refResult = await gh({ token: params.token, method: "GET", path: branchPath });
150425
+ const branchExists = refResult.status === 200;
150426
+ if (branchExists) {
150427
+ const ref = refResult.json;
150428
+ const remoteTip = ref.object.sha;
150429
+ if (!params.parents.includes(remoteTip)) {
150430
+ throw new Error(
150431
+ isAncestorOfHead(remoteTip) ? `your local branch has commits that were never pushed \u2014 signed-commits mode can't push local commits. run git reset --mixed ${remoteTip} (keeps every change in the working tree), then retry commit_changes.` : `the remote branch '${params.remoteBranch}' has commits you don't have locally (tip ${remoteTip.slice(0, 7)}). fetch it with git_fetch, integrate with git merge --no-commit origin/${params.remoteBranch}, resolve any conflicts, git add the results, then retry commit_changes.`
150432
+ );
150433
+ }
150434
+ } else if (refResult.status !== 404) {
150435
+ throw ghError("GET", branchPath, refResult);
150436
+ }
150437
+ const baseParent = params.parents[0];
150438
+ if (!baseParent) throw new Error("createSignedCommit requires at least one parent");
150439
+ const baseTree = $("git", ["rev-parse", `${baseParent}^{tree}`], { log: false }).trim();
150440
+ let treeSha = baseTree;
150441
+ if (params.files.length > 0) {
150442
+ const repoRoot = getRepoRoot();
150443
+ const additions = params.files.filter((f) => !f.deleted);
150444
+ const blobEntries = [];
150445
+ for (let i = 0; i < additions.length; i += BLOB_UPLOAD_CONCURRENCY) {
150446
+ const chunk = additions.slice(i, i + BLOB_UPLOAD_CONCURRENCY);
150447
+ blobEntries.push(
150448
+ ...await Promise.all(
150449
+ chunk.map(
150450
+ (f) => createBlobEntry({ token: params.token, repoPath, repoRoot, path: f.path })
150451
+ )
150452
+ )
150453
+ );
150454
+ }
150455
+ const deletionEntries = params.files.filter((f) => f.deleted).map((f) => ({ path: f.path, mode: "100644", type: "blob", sha: null }));
150456
+ const treeResult = await gh({
150457
+ token: params.token,
150458
+ method: "POST",
150459
+ path: `${repoPath}/git/trees`,
150460
+ body: { base_tree: baseTree, tree: [...blobEntries, ...deletionEntries] }
150461
+ });
150462
+ if (treeResult.status !== 201) {
150463
+ throw wrapUnknownBaseError("POST", `${repoPath}/git/trees`, treeResult, params.remoteBranch);
150464
+ }
150465
+ treeSha = treeResult.json.sha;
150466
+ }
150467
+ const commitResult = await gh({
150468
+ token: params.token,
150469
+ method: "POST",
150470
+ path: `${repoPath}/git/commits`,
150471
+ body: { message: params.message, tree: treeSha, parents: params.parents }
150472
+ });
150473
+ if (commitResult.status !== 201) {
150474
+ throw wrapUnknownBaseError(
150475
+ "POST",
150476
+ `${repoPath}/git/commits`,
150477
+ commitResult,
150478
+ params.remoteBranch
150479
+ );
150480
+ }
150481
+ const commit = commitResult.json;
150482
+ if (branchExists) {
150483
+ await updateRefWithRetry({
150484
+ token: params.token,
150485
+ repoPath,
150486
+ remoteBranch: params.remoteBranch,
150487
+ sha: commit.sha
150488
+ });
150489
+ } else {
150490
+ const createResult = await gh({
150491
+ token: params.token,
150492
+ method: "POST",
150493
+ path: `${repoPath}/git/refs`,
150494
+ body: { ref: `refs/heads/${params.remoteBranch}`, sha: commit.sha }
150495
+ });
150496
+ if (createResult.status !== 201) {
150497
+ throw ghError("POST", `${repoPath}/git/refs`, createResult);
150498
+ }
150499
+ }
150500
+ return { sha: commit.sha, createdBranch: !branchExists };
150501
+ }
150502
+ function isAncestorOfHead(sha) {
150503
+ try {
150504
+ $("git", ["merge-base", "--is-ancestor", sha, "HEAD"], { log: false });
150505
+ return true;
150506
+ } catch {
150507
+ return false;
150508
+ }
150509
+ }
150510
+ function wrapUnknownBaseError(method, path4, result, remoteBranch) {
150511
+ if (result.status === 404 || result.status === 422) {
150512
+ return new Error(
150513
+ `${ghError(method, path4, result).message}
150514
+
150515
+ this usually means your local branch has commits that were never pushed \u2014 signed-commits mode can't push local commits. run git reset --mixed origin/${remoteBranch} (or the commit you branched from; this keeps every change in the working tree), then retry commit_changes.`
150516
+ );
150517
+ }
150518
+ return ghError(method, path4, result);
150519
+ }
150520
+
150521
+ // mcp/git.ts
150132
150522
  function getPushDestination(branch, storedDest) {
150133
150523
  if (storedDest && storedDest.localBranch === branch) {
150134
150524
  log.debug(`using stored push destination: ${storedDest.remoteName}/${storedDest.remoteBranch}`);
@@ -150186,6 +150576,10 @@ function validateTagName(tag) {
150186
150576
  );
150187
150577
  }
150188
150578
  }
150579
+ function pushesToBaseRepo(ctx) {
150580
+ const baseUrl = `https://github.com/${ctx.repo.owner}/${ctx.repo.name}.git`;
150581
+ return normalizeUrl(ctx.toolState.pushUrl ?? "") === normalizeUrl(baseUrl);
150582
+ }
150189
150583
  function validatePushDestination(ctx, branch) {
150190
150584
  const pushUrl = ctx.toolState.pushUrl;
150191
150585
  if (!pushUrl) throw new Error("pushUrl not set - setupGit must run before push_branch");
@@ -150204,6 +150598,47 @@ var PushBranch = type({
150204
150598
  branchName: type.string.describe("The branch name to push (defaults to current branch)").optional(),
150205
150599
  force: type.boolean.describe("Force push (use with caution)").default(false)
150206
150600
  });
150601
+ function assertPushTarget(ctx, branch, pushDest) {
150602
+ const prBranchMatch = branch.match(/^pr-(\d+)$/);
150603
+ if (prBranchMatch && pushDest.remoteBranch !== branch) {
150604
+ const prNumber = Number(prBranchMatch[1]);
150605
+ const event = ctx.payload.event;
150606
+ const runScoped = event.is_pr === true && event.issue_number === prNumber;
150607
+ if (!runScoped) {
150608
+ throw new Error(
150609
+ `push blocked: local branch '${branch}' would push to '${pushDest.remoteName}/${pushDest.remoteBranch}', but this run is not scoped to PR #${prNumber}. the 'pr-${prNumber}' branch was created by a prior checkout_pr call (likely from a subagent \u2014 subagents share the working tree and toolState with the orchestrator). you have probably landed your commit on the wrong branch. switch to your own feature branch first (e.g. 'git checkout <feature-branch>') and then push. if the push to PR #${prNumber} is intentional, this run needs to be triggered against that PR.`
150610
+ );
150611
+ }
150612
+ }
150613
+ const defaultBranch = ctx.repo.data.default_branch || "main";
150614
+ if (ctx.payload.push === "restricted" && pushDest.remoteBranch === defaultBranch) {
150615
+ throw new Error(
150616
+ `Push blocked: cannot push directly to default branch '${pushDest.remoteBranch}'. Create a feature branch and open a PR instead.`
150617
+ );
150618
+ }
150619
+ }
150620
+ async function runPrepushHook(ctx, retryTool) {
150621
+ if (ctx.toolState.prepushFailureCount > 0) {
150622
+ log.info(`\xBB skipping prepush hook (failed earlier this run)`);
150623
+ return true;
150624
+ }
150625
+ if (!ctx.prepushScript) return false;
150626
+ const prepushHook = await executeLifecycleHook({
150627
+ event: "prepush",
150628
+ script: ctx.prepushScript
150629
+ });
150630
+ if (prepushHook.failure) {
150631
+ ctx.toolState.prepushFailureCount += 1;
150632
+ throw new Error(
150633
+ buildPrepushFailureMessage({
150634
+ failure: prepushHook.failure,
150635
+ shell: ctx.payload.shell,
150636
+ retryTool
150637
+ })
150638
+ );
150639
+ }
150640
+ return false;
150641
+ }
150207
150642
  var CONCURRENT_PUSH_PATTERNS = ["fetch first", "non-fast-forward", "cannot lock ref"];
150208
150643
  var TRANSIENT_PATTERNS = [
150209
150644
  /RPC failed/i,
@@ -150263,7 +150698,6 @@ async function pushWithRetry(args2, token) {
150263
150698
  throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
150264
150699
  }
150265
150700
  function PushBranchTool(ctx) {
150266
- const defaultBranch = ctx.repo.data.default_branch || "main";
150267
150701
  const pushPermission = ctx.payload.push;
150268
150702
  return tool({
150269
150703
  name: "push_branch",
@@ -150275,6 +150709,11 @@ function PushBranchTool(ctx) {
150275
150709
  }
150276
150710
  const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
150277
150711
  rejectSpecialRef(branch, "branch");
150712
+ if (ctx.signedCommits && pushesToBaseRepo(ctx)) {
150713
+ throw new Error(
150714
+ "push_branch is not used in signed-commits mode \u2014 commits land on the remote via the commit_changes tool. call commit_changes to commit your working-tree changes as a GitHub-signed commit. if you already called commit_changes, your work is already on the remote \u2014 there is nothing left to push."
150715
+ );
150716
+ }
150278
150717
  const status = $("git", ["status", "--porcelain"], { log: false });
150279
150718
  if (status) {
150280
150719
  throw new Error(
@@ -150285,36 +150724,11 @@ ${status}` + (ctx.toolState.prepushFailureCount > 0 ? "\n\nnote: the prepush hoo
150285
150724
  );
150286
150725
  }
150287
150726
  const pushDest = validatePushDestination(ctx, branch);
150288
- const prBranchMatch = branch.match(/^pr-(\d+)$/);
150289
- if (prBranchMatch && pushDest.remoteBranch !== branch) {
150290
- const prNumber = Number(prBranchMatch[1]);
150291
- const event = ctx.payload.event;
150292
- const runScoped = event.is_pr === true && event.issue_number === prNumber;
150293
- if (!runScoped) {
150294
- throw new Error(
150295
- `push blocked: local branch '${branch}' would push to '${pushDest.remoteName}/${pushDest.remoteBranch}', but this run is not scoped to PR #${prNumber}. the 'pr-${prNumber}' branch was created by a prior checkout_pr call (likely from a subagent \u2014 subagents share the working tree and toolState with the orchestrator). you have probably landed your commit on the wrong branch. switch to your own feature branch first (e.g. 'git checkout <feature-branch>') and then push. if the push to PR #${prNumber} is intentional, this run needs to be triggered against that PR.`
150296
- );
150297
- }
150298
- }
150299
- if (pushPermission === "restricted" && pushDest.remoteBranch === defaultBranch) {
150300
- throw new Error(
150301
- `Push blocked: cannot push directly to default branch '${pushDest.remoteBranch}'. Create a feature branch and open a PR instead.`
150302
- );
150303
- }
150727
+ assertPushTarget(ctx, branch, pushDest);
150304
150728
  const refspec = branch === pushDest.remoteBranch ? branch : `${branch}:${pushDest.remoteBranch}`;
150305
150729
  const pushArgs = force ? ["--force", "-u", pushDest.remoteName, refspec] : ["-u", pushDest.remoteName, refspec];
150306
- const prepushSkipped = ctx.toolState.prepushFailureCount > 0;
150307
- if (prepushSkipped) {
150308
- log.info(`\xBB skipping prepush hook (failed earlier this run)`);
150309
- } else if (ctx.prepushScript) {
150310
- const prepushHook = await executeLifecycleHook({
150311
- event: "prepush",
150312
- script: ctx.prepushScript
150313
- });
150314
- if (prepushHook.failure) {
150315
- ctx.toolState.prepushFailureCount += 1;
150316
- throw new Error(buildPrepushFailureMessage(prepushHook.failure, ctx.payload.shell));
150317
- }
150730
+ const prepushSkipped = await runPrepushHook(ctx, "push_branch");
150731
+ if (!prepushSkipped && ctx.prepushScript) {
150318
150732
  const postHookStatus = $("git", ["status", "--porcelain"], { log: false });
150319
150733
  if (postHookStatus) {
150320
150734
  throw new Error(
@@ -150365,15 +150779,138 @@ ${integrateStep}
150365
150779
  })
150366
150780
  });
150367
150781
  }
150368
- function buildPrepushFailureMessage(failure, shell) {
150782
+ function buildPrepushFailureMessage(params) {
150783
+ const failure = params.failure;
150369
150784
  const header = failure.kind === "exit" ? `prepush hook failed with exit code ${failure.exitCode}.
150370
150785
 
150371
150786
  script output:
150372
150787
  ${failure.output || "(empty)"}` : failure.kind === "timeout" ? `prepush hook timed out \u2014 the script is hung or doing too much work.` : `prepush hook failed to spawn: ${failure.spawnError}.`;
150373
- const ifRealBug = shell === "disabled" ? `fix it before pushing again \u2014 shell access is disabled in this run, so you can't re-run the hook command yourself.` : `run the hook command yourself via the shell tool to iterate (push_branch will NOT re-run it).`;
150788
+ const ifRealBug = params.shell === "disabled" ? `fix it before pushing again \u2014 shell access is disabled in this run, so you can't re-run the hook command yourself.` : `run the hook command yourself via the shell tool to iterate (${params.retryTool} will NOT re-run it).`;
150374
150789
  return `${header}
150375
150790
 
150376
- this repo's prepush hook is best-effort: the next push_branch call will SKIP the hook and proceed. if the failure is unrelated to your changes (pre-existing breakage, flaky check), just call push_branch again. if it could be a real bug in your code, ${ifRealBug}`;
150791
+ this repo's prepush hook is best-effort: the next ${params.retryTool} call will SKIP the hook and proceed. if the failure is unrelated to your changes (pre-existing breakage, flaky check), just call ${params.retryTool} again. if it could be a real bug in your code, ${ifRealBug}`;
150792
+ }
150793
+ function buildNothingToCommitMessage(pushDest) {
150794
+ const base = "nothing to commit \u2014 the working tree matches HEAD.";
150795
+ try {
150796
+ const remoteTip = $(
150797
+ "git",
150798
+ ["rev-parse", `refs/remotes/${pushDest.remoteName}/${pushDest.remoteBranch}`],
150799
+ { log: false }
150800
+ ).trim();
150801
+ const head = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
150802
+ if (remoteTip === head) {
150803
+ return `${base} your work is already on ${pushDest.remoteName}/${pushDest.remoteBranch} \u2014 there is no push step in signed-commits mode.`;
150804
+ }
150805
+ $("git", ["merge-base", "--is-ancestor", remoteTip, "HEAD"], { log: false });
150806
+ return `${base} but your local branch has commits that were never pushed \u2014 signed-commits mode can't push local commits. run git reset --mixed ${remoteTip} (keeps every change in the working tree), then retry commit_changes.`;
150807
+ } catch {
150808
+ return base;
150809
+ }
150810
+ }
150811
+ var CommitChanges = type({
150812
+ message: type.string.describe("Commit message (first line = subject)"),
150813
+ files: type.string.array().describe("Optional subset of changed paths to commit. Defaults to every working-tree change.").optional()
150814
+ });
150815
+ function CommitChangesTool(ctx) {
150816
+ const pushPermission = ctx.payload.push;
150817
+ return tool({
150818
+ name: "commit_changes",
150819
+ description: "Commit working-tree changes directly to the remote branch as a GitHub-signed (Verified) commit \u2014 this repository has signed commits enabled, so use this INSTEAD of git commit + push_branch. Edit files locally, then call this tool: it detects every working-tree change (new, modified, deleted files), or commits a subset via `files`. The commit lands on the remote immediately \u2014 there is no separate push step. The remote branch is created automatically on the first commit to a new local branch. A merge in progress (git merge --no-commit) is concluded as a signed merge commit \u2014 resolve conflicts and git add first. Runs the repository prepush hook (if configured) before committing \u2014 best-effort, same skip-on-failure behavior as push_branch.",
150820
+ parameters: CommitChanges,
150821
+ timeoutMs: 6e5,
150822
+ execute: execute(async (params) => {
150823
+ if (pushPermission === "disabled") {
150824
+ throw new Error("Push is disabled. This repository is configured for read-only access.");
150825
+ }
150826
+ const branch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }).trim();
150827
+ if (branch === "HEAD") {
150828
+ throw new Error(
150829
+ "HEAD is detached \u2014 create or check out a branch before committing (e.g. git checkout -b pullfrog/<description>)."
150830
+ );
150831
+ }
150832
+ rejectSpecialRef(branch, "branch");
150833
+ const pushDest = validatePushDestination(ctx, branch);
150834
+ if (!pushesToBaseRepo(ctx)) {
150835
+ throw new Error(
150836
+ `'${branch}' pushes to the fork '${pushDest.url}', where the app can't create signed commits. commit locally via the git tool and use push_branch instead (those commits will be unsigned).`
150837
+ );
150838
+ }
150839
+ assertPushTarget(ctx, branch, pushDest);
150840
+ const prepushSkipped = await runPrepushHook(ctx, "commit_changes");
150841
+ const head = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
150842
+ let mergeHead = "";
150843
+ try {
150844
+ mergeHead = $("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], { log: false }).trim();
150845
+ } catch {
150846
+ }
150847
+ let changes = detectWorkingTreeChanges();
150848
+ if (params.files) {
150849
+ if (mergeHead) {
150850
+ throw new Error(
150851
+ "can't commit a subset of files while a merge is in progress \u2014 the merge commit must include every merged change. omit `files`."
150852
+ );
150853
+ }
150854
+ const requested = new Set(params.files);
150855
+ const known = new Set(changes.map((c) => c.path));
150856
+ const unknown4 = [...requested].filter((p) => !known.has(p));
150857
+ if (unknown4.length > 0) {
150858
+ throw new Error(
150859
+ `no detected change at: ${unknown4.join(", ")} \u2014 run git status to list changed paths.`
150860
+ );
150861
+ }
150862
+ changes = changes.filter((c) => requested.has(c.path));
150863
+ }
150864
+ if (changes.length === 0 && !mergeHead) {
150865
+ throw new Error(buildNothingToCommitMessage(pushDest));
150866
+ }
150867
+ await assertApiCommittable(changes);
150868
+ const parents = mergeHead ? [head, mergeHead] : [head];
150869
+ const result = await createSignedCommit({
150870
+ token: ctx.gitToken,
150871
+ owner: ctx.repo.owner,
150872
+ repo: ctx.repo.name,
150873
+ remoteBranch: pushDest.remoteBranch,
150874
+ message: params.message,
150875
+ parents,
150876
+ files: changes
150877
+ });
150878
+ await $git(
150879
+ "fetch",
150880
+ [
150881
+ "--no-tags",
150882
+ "origin",
150883
+ `+refs/heads/${pushDest.remoteBranch}:refs/remotes/origin/${pushDest.remoteBranch}`
150884
+ ],
150885
+ { token: ctx.gitToken }
150886
+ );
150887
+ $("git", ["update-ref", `refs/heads/${branch}`, result.sha], { log: false });
150888
+ if (mergeHead) {
150889
+ $("git", ["merge", "--quit"], { log: false });
150890
+ }
150891
+ $("git", ["reset", "-q"], { log: false });
150892
+ if (result.createdBranch) {
150893
+ $("git", ["config", `branch.${branch}.remote`, "origin"], { log: false });
150894
+ $("git", ["config", `branch.${branch}.merge`, `refs/heads/${pushDest.remoteBranch}`], {
150895
+ log: false
150896
+ });
150897
+ }
150898
+ log.info(
150899
+ `\xBB created signed commit ${result.sha.slice(0, 7)} (${changes.length} file(s)) on ${pushDest.remoteName}/${pushDest.remoteBranch}`
150900
+ );
150901
+ return {
150902
+ success: true,
150903
+ sha: result.sha,
150904
+ branch,
150905
+ remoteBranch: pushDest.remoteBranch,
150906
+ files: changes.map((c) => c.deleted ? `D ${c.path}` : c.path),
150907
+ createdBranch: result.createdBranch,
150908
+ verified: true,
150909
+ prepushSkipped,
150910
+ message: `created signed commit ${result.sha.slice(0, 7)} on ${pushDest.remoteName}/${pushDest.remoteBranch}${result.createdBranch ? " (remote branch created)" : ""}`
150911
+ };
150912
+ })
150913
+ });
150377
150914
  }
150378
150915
  var AUTH_REQUIRED_REDIRECT = {
150379
150916
  push: "use the push_branch tool instead \u2014 it handles authentication and permission checks.",
@@ -150446,7 +150983,7 @@ function countAhead(head, base) {
150446
150983
  function spillGitOutput(params) {
150447
150984
  const tempDir = process.env.PULLFROG_TEMP_DIR;
150448
150985
  if (!tempDir) throw new Error("PULLFROG_TEMP_DIR not set");
150449
- const outputPath = join10(tempDir, `git-${params.command}-${randomUUID3().slice(0, 8)}.txt`);
150986
+ const outputPath = join12(tempDir, `git-${params.command}-${randomUUID3().slice(0, 8)}.txt`);
150450
150987
  writeFileSync6(outputPath, params.output);
150451
150988
  const previewByLines = params.output.split("\n").slice(0, OVERFLOW_PREVIEW_LINES).join("\n");
150452
150989
  const preview = previewByLines.length <= OVERFLOW_PREVIEW_MAX_CHARS ? previewByLines : `${previewByLines.slice(0, OVERFLOW_PREVIEW_MAX_CHARS)}\u2026`;
@@ -150480,8 +151017,30 @@ function GitTool(ctx) {
150480
151017
  }
150481
151018
  const redirect = AUTH_REQUIRED_REDIRECT[command];
150482
151019
  if (redirect) {
151020
+ if (command === "push" && ctx.signedCommits) {
151021
+ throw new Error(
151022
+ "git push is not available through this tool \u2014 in signed-commits mode use commit_changes instead: it commits your working-tree changes directly to the remote as a GitHub-signed commit (push_branch only applies to fork PRs)."
151023
+ );
151024
+ }
150483
151025
  throw new Error(`git ${command} is not available through this tool \u2014 ${redirect}`);
150484
151026
  }
151027
+ if (ctx.signedCommits && (command === "commit" || command === "merge")) {
151028
+ if (pushesToBaseRepo(ctx)) {
151029
+ if (command === "commit") {
151030
+ throw new Error(
151031
+ "git commit is blocked in signed-commits mode \u2014 use the commit_changes tool instead. it commits your working-tree changes directly to the remote as a GitHub-signed (Verified) commit. if you are concluding a merge, stage the resolutions with git add and call commit_changes \u2014 no local commit is needed."
151032
+ );
151033
+ }
151034
+ const noLocalCommit = args2.some(
151035
+ (a) => a === "--no-commit" || a === "--abort" || a === "--quit"
151036
+ );
151037
+ if (!noLocalCommit) {
151038
+ throw new Error(
151039
+ "bare git merge would create a local commit, which can't be pushed in signed-commits mode. use git merge --no-commit <ref>, resolve any conflicts, git add the results, then call commit_changes \u2014 it concludes the merge as a signed merge commit."
151040
+ );
151041
+ }
151042
+ }
151043
+ }
150485
151044
  if (ctx.payload.shell === "disabled") {
150486
151045
  const blocked = NOSHELL_BLOCKED_SUBCOMMANDS[command];
150487
151046
  if (blocked) {
@@ -151114,7 +151673,6 @@ async function createAndSubmitWithFooter(ctx, params, opts) {
151114
151673
  workflowRun: ctx.runId ? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId } : void 0,
151115
151674
  customParts,
151116
151675
  model: ctx.toolState.model,
151117
- fallbackFrom: ctx.toolState.modelFallback?.from,
151118
151676
  oss: ctx.oss
151119
151677
  });
151120
151678
  return await ctx.octokit.rest.pulls.submitReview({
@@ -151147,12 +151705,12 @@ async function reportReviewNodeId(ctx, params) {
151147
151705
  }
151148
151706
 
151149
151707
  // utils/setup.ts
151150
- import { execFileSync as execFileSync6, execSync as execSync2 } from "node:child_process";
151708
+ import { execFileSync as execFileSync7, execSync as execSync2 } from "node:child_process";
151151
151709
  import { mkdtempSync, readdirSync, realpathSync as realpathSync2, unlinkSync as unlinkSync2 } from "node:fs";
151152
151710
  import { tmpdir as tmpdir2 } from "node:os";
151153
- import { join as join11 } from "node:path";
151711
+ import { join as join13 } from "node:path";
151154
151712
  function createTempDirectory() {
151155
- const sharedTempDir = mkdtempSync(join11(tmpdir2(), "pullfrog-"));
151713
+ const sharedTempDir = mkdtempSync(join13(tmpdir2(), "pullfrog-"));
151156
151714
  process.env.PULLFROG_TEMP_DIR = sharedTempDir;
151157
151715
  log.info(`\xBB created temp dir at ${sharedTempDir}`);
151158
151716
  return sharedTempDir;
@@ -151197,13 +151755,13 @@ function wipeRunnerLeakSurface() {
151197
151755
  return [];
151198
151756
  }
151199
151757
  };
151200
- const fileCommandsDir = join11(runnerTemp, "_runner_file_commands");
151758
+ const fileCommandsDir = join13(runnerTemp, "_runner_file_commands");
151201
151759
  for (const entry of listDir(fileCommandsDir)) {
151202
- tryUnlink(join11(fileCommandsDir, entry));
151760
+ tryUnlink(join13(fileCommandsDir, entry));
151203
151761
  }
151204
151762
  for (const entry of listDir(runnerTemp)) {
151205
151763
  if (entry.endsWith(".sh") || /^git-credentials-.*\.config$/.test(entry)) {
151206
- tryUnlink(join11(runnerTemp, entry));
151764
+ tryUnlink(join13(runnerTemp, entry));
151207
151765
  }
151208
151766
  }
151209
151767
  if (wiped.length > 0) {
@@ -151240,7 +151798,7 @@ function removeIncludeIfEntries(repoDir) {
151240
151798
  if (!key || seen.has(key)) continue;
151241
151799
  seen.add(key);
151242
151800
  try {
151243
- execFileSync6("git", ["config", "--local", "--unset-all", key], {
151801
+ execFileSync7("git", ["config", "--local", "--unset-all", key], {
151244
151802
  cwd: repoDir,
151245
151803
  stdio: "pipe",
151246
151804
  env: env2
@@ -151712,7 +152270,7 @@ function CheckoutPrTool(ctx) {
151712
152270
  headSha: ctx.toolState.checkoutSha
151713
152271
  });
151714
152272
  if (incremental) {
151715
- incrementalDiffPath = join12(
152273
+ incrementalDiffPath = join14(
151716
152274
  tempDir,
151717
152275
  `pr-${pull_number}-${beforeShort}-${headShort}-incremental.diff`
151718
152276
  );
@@ -151726,7 +152284,7 @@ function CheckoutPrTool(ctx) {
151726
152284
  const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
151727
152285
  log.debug(`formatted diff preview (first 100 lines):
151728
152286
  ${diffPreview}`);
151729
- const diffPath = join12(tempDir, `pr-${pull_number}-${headShort}.diff`);
152287
+ const diffPath = join14(tempDir, `pr-${pull_number}-${headShort}.diff`);
151730
152288
  writeFileSync7(diffPath, formatResult.content);
151731
152289
  log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
151732
152290
  ctx.toolState.diffCoverage = createDiffCoverageState({
@@ -151835,7 +152393,7 @@ ${dirty}`
151835
152393
 
151836
152394
  // mcp/checkSuite.ts
151837
152395
  import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync8 } from "node:fs";
151838
- import { join as join13 } from "node:path";
152396
+ import { join as join15 } from "node:path";
151839
152397
  var GetCheckSuiteLogs = type({
151840
152398
  check_suite_id: type.number.describe("the id from check_suite.id")
151841
152399
  });
@@ -151931,7 +152489,7 @@ function GetCheckSuiteLogsTool(ctx) {
151931
152489
  if (!tempDir) {
151932
152490
  throw new Error("PULLFROG_TEMP_DIR not set");
151933
152491
  }
151934
- const logsDir = join13(tempDir, "ci-logs");
152492
+ const logsDir = join15(tempDir, "ci-logs");
151935
152493
  mkdirSync7(logsDir, { recursive: true });
151936
152494
  const jobResults = [];
151937
152495
  for (const run of failedRuns) {
@@ -151958,7 +152516,7 @@ function GetCheckSuiteLogsTool(ctx) {
151958
152516
  );
151959
152517
  }
151960
152518
  const logsText = await logsResult.text();
151961
- const logPath = join13(logsDir, `job-${job.id}.log`);
152519
+ const logPath = join15(logsDir, `job-${job.id}.log`);
151962
152520
  writeFileSync8(logPath, logsText);
151963
152521
  const analysis = analyzeLog(logsText, 80);
151964
152522
  const failedSteps = job.steps?.filter((s) => s.conclusion === "failure").map((s) => `Step ${s.number}: ${s.name}`) ?? [];
@@ -152008,7 +152566,7 @@ function GetCheckSuiteLogsTool(ctx) {
152008
152566
 
152009
152567
  // mcp/commitInfo.ts
152010
152568
  import { writeFileSync as writeFileSync9 } from "node:fs";
152011
- import { join as join14 } from "node:path";
152569
+ import { join as join16 } from "node:path";
152012
152570
  var CommitInfo = type({
152013
152571
  sha: type.string.describe("the commit SHA (full or abbreviated) to fetch")
152014
152572
  });
@@ -152032,7 +152590,7 @@ function CommitInfoTool(ctx) {
152032
152590
  "PULLFROG_TEMP_DIR not set - get_commit_info must run in pullfrog action context"
152033
152591
  );
152034
152592
  }
152035
- const diffFile = join14(tempDir, `commit-${sha.slice(0, 7)}.diff`);
152593
+ const diffFile = join16(tempDir, `commit-${sha.slice(0, 7)}.diff`);
152036
152594
  writeFileSync9(diffFile, formatResult.content);
152037
152595
  log.debug(`wrote commit diff to ${diffFile} (${formatResult.content.length} bytes)`);
152038
152596
  return {
@@ -152539,7 +153097,6 @@ function buildPrBodyWithFooter(ctx, body) {
152539
153097
  triggeredBy: true,
152540
153098
  workflowRun: ctx.runId ? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId } : void 0,
152541
153099
  model: ctx.toolState.model,
152542
- fallbackFrom: ctx.toolState.modelFallback?.from,
152543
153100
  oss: ctx.oss
152544
153101
  });
152545
153102
  const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
@@ -152694,7 +153251,7 @@ function PullRequestInfoTool(ctx) {
152694
153251
 
152695
153252
  // mcp/reviewComments.ts
152696
153253
  import { writeFileSync as writeFileSync10 } from "node:fs";
152697
- import { join as join15 } from "node:path";
153254
+ import { join as join17 } from "node:path";
152698
153255
  var REVIEW_THREADS_QUERY = `
152699
153256
  query ($owner: String!, $name: String!, $prNumber: Int!) {
152700
153257
  repository(owner: $owner, name: $name) {
@@ -153110,7 +153667,7 @@ function GetReviewCommentsTool(ctx) {
153110
153667
  throw new Error("PULLFROG_TEMP_DIR not set");
153111
153668
  }
153112
153669
  const filename = `review-${params.review_id}-threads.md`;
153113
- const commentsPath = join15(tempDir, filename);
153670
+ const commentsPath = join17(tempDir, filename);
153114
153671
  writeFileSync10(commentsPath, formatted.content);
153115
153672
  log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`);
153116
153673
  return {
@@ -153348,7 +153905,7 @@ import { spawn as spawn2, spawnSync as spawnSync5 } from "node:child_process";
153348
153905
  import { randomUUID as randomUUID4 } from "node:crypto";
153349
153906
  import { closeSync, openSync, writeFileSync as writeFileSync11 } from "node:fs";
153350
153907
  import { userInfo as userInfo2 } from "node:os";
153351
- import { join as join16 } from "node:path";
153908
+ import { join as join18 } from "node:path";
153352
153909
  import { setTimeout as sleep2 } from "node:timers/promises";
153353
153910
  var ShellParams = type({
153354
153911
  command: "string",
@@ -153511,7 +154068,7 @@ function getTempDir() {
153511
154068
  var MAX_OUTPUT_CHARS = 5e3;
153512
154069
  function capOutput(output) {
153513
154070
  if (output.length <= MAX_OUTPUT_CHARS) return output;
153514
- const fullPath = join16(getTempDir(), `shell-${randomUUID4().slice(0, 8)}.log`);
154071
+ const fullPath = join18(getTempDir(), `shell-${randomUUID4().slice(0, 8)}.log`);
153515
154072
  writeFileSync11(fullPath, output);
153516
154073
  const elided = output.length - MAX_OUTPUT_CHARS;
153517
154074
  return `... [${elided} chars truncated; full output saved to ${fullPath}] ...
@@ -153566,8 +154123,8 @@ Do NOT use this tool for git commands \u2014 use the dedicated git tools instead
153566
154123
  if (params.background) {
153567
154124
  const tempDir = getTempDir();
153568
154125
  const handle = `bg-${randomUUID4().slice(0, 8)}`;
153569
- const outputPath = join16(tempDir, `${handle}.log`);
153570
- const pidPath = join16(tempDir, `${handle}.pid`);
154126
+ const outputPath = join18(tempDir, `${handle}.log`);
154127
+ const pidPath = join18(tempDir, `${handle}.pid`);
153571
154128
  const logFd = openSync(outputPath, "a");
153572
154129
  let proc2;
153573
154130
  try {
@@ -153798,7 +154355,7 @@ function buildCommonTools(ctx, outputSchema) {
153798
154355
  return tools;
153799
154356
  }
153800
154357
  function buildOrchestratorTools(ctx, outputSchema) {
153801
- return [
154358
+ const tools = [
153802
154359
  ...buildCommonTools(ctx, outputSchema),
153803
154360
  ReportProgressTool(ctx),
153804
154361
  SelectModeTool(ctx),
@@ -153808,6 +154365,10 @@ function buildOrchestratorTools(ctx, outputSchema) {
153808
154365
  CreatePullRequestTool(ctx),
153809
154366
  UpdatePullRequestBodyTool(ctx)
153810
154367
  ];
154368
+ if (ctx.signedCommits) {
154369
+ tools.push(CommitChangesTool(ctx));
154370
+ }
154371
+ return tools;
153811
154372
  }
153812
154373
  async function tryStartMcpServer(ctx, tools, port) {
153813
154374
  const server = new FastMCP({ name: pullfrogMcpName, version: "0.0.1" });
@@ -153998,8 +154559,14 @@ var MISSING_KEY_MARKER = "no API key found";
153998
154559
  function buildMissingApiKeyError(params) {
153999
154560
  const githubSecretsUrl = `https://github.com/${params.owner}/${params.name}/settings/secrets/actions`;
154000
154561
  const settingsUrl = `${getApiUrl()}/console/${params.owner}/${params.name}`;
154562
+ const envVars = params.model?.includes("/") ? getModelEnvVars(params.model) : [];
154563
+ const [primary, ...alternates] = envVars;
154564
+ const envVarList = primary ? `\`${primary}\`${alternates.length > 0 ? ` (or ${alternates.map((v) => `\`${v}\``).join(" / ")})` : ""}` : void 0;
154565
+ const lead = envVarList ? `**${MISSING_KEY_MARKER}** \u2014 this repo is configured to use \`${params.model}\`, which needs ${envVarList}, but the runner has no key for it.` : `**${MISSING_KEY_MARKER}** \u2014 Pullfrog needs at least one LLM provider API key (e.g. \`ANTHROPIC_API_KEY\`, \`OPENAI_API_KEY\`, \`GEMINI_API_KEY\`) configured as a GitHub Actions secret.`;
154001
154566
  return [
154002
- `**${MISSING_KEY_MARKER}** \u2014 Pullfrog needs at least one LLM provider API key (e.g. \`ANTHROPIC_API_KEY\`, \`OPENAI_API_KEY\`, \`GEMINI_API_KEY\`) configured as a GitHub Actions secret.`,
154567
+ lead,
154568
+ "",
154569
+ "**To fix:** add the key as a GitHub Actions secret (referenced from your workflow's `env:` block) or as a Pullfrog secret in the console \u2014 or switch this repo to a different model (free models need no key).",
154003
154570
  "",
154004
154571
  `[Open repo secrets \u2192](${githubSecretsUrl}) \xB7 [Configure model \u2192](${settingsUrl}) \xB7 [Setup docs \u2192](https://docs.pullfrog.com/keys) \xB7 [Ask in Discord \u2192](https://discord.gg/8y96raFg8e)`
154005
154572
  ].join("\n");
@@ -154079,10 +154646,14 @@ function validateAgentApiKey(params) {
154079
154646
  }
154080
154647
  if (params.agent.name === "opencode") {
154081
154648
  if (params.authorized.has(params.model)) return;
154082
- throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
154649
+ throw new Error(
154650
+ buildMissingApiKeyError({ owner: params.owner, name: params.name, model: params.model })
154651
+ );
154083
154652
  }
154084
154653
  if (hasEnvVar3("ANTHROPIC_API_KEY") || hasEnvVar3("CLAUDE_CODE_OAUTH_TOKEN")) return;
154085
- throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
154654
+ throw new Error(
154655
+ buildMissingApiKeyError({ owner: params.owner, name: params.name, model: params.model })
154656
+ );
154086
154657
  }
154087
154658
  if (params.agent.name === "opencode") {
154088
154659
  if (params.authorized.size > 0) return;
@@ -154093,42 +154664,37 @@ function validateAgentApiKey(params) {
154093
154664
  }
154094
154665
  function isApiKeyAuthError(text) {
154095
154666
  if (!text) return false;
154096
- return text.includes(MISSING_KEY_MARKER) || /Invalid API key/i.test(text) || /\bUser not found\b/i.test(text) || /\bInvalid authentication\b/i.test(text) || /authentication_error/i.test(text) || /Invalid bearer token/i.test(text) || /api_error_status\s*=\s*401/i.test(text) || /API Error:\s*401/i.test(text);
154667
+ return text.includes(MISSING_KEY_MARKER) || /Invalid API key/i.test(text) || /\bUser not found\b/i.test(text) || /\bInvalid authentication\b/i.test(text) || /authentication_error/i.test(text) || /Invalid bearer token/i.test(text) || /api_error_status\s*=\s*401/i.test(text) || /API Error:\s*401/i.test(text) || /Failed to authenticate\. API Error:/i.test(text) || isOAuthCredentialExpiredError(text);
154668
+ }
154669
+ function isOAuthCredentialExpiredError(text) {
154670
+ return /authentication token has expired/i.test(text) || /Token refresh failed/i.test(text);
154097
154671
  }
154098
154672
  function formatApiKeyErrorSummary(params) {
154099
154673
  if (params.raw.includes(MISSING_KEY_MARKER)) {
154674
+ if (params.raw.startsWith(`**${MISSING_KEY_MARKER}**`)) return params.raw;
154100
154675
  return buildMissingApiKeyError({ owner: params.owner, name: params.name });
154101
154676
  }
154102
154677
  const githubSecretsUrl = `https://github.com/${params.owner}/${params.name}/settings/secrets/actions`;
154103
154678
  const settingsUrl = `${getApiUrl()}/console/${params.owner}/${params.name}`;
154679
+ if (isOAuthCredentialExpiredError(params.raw)) {
154680
+ return [
154681
+ `**Your provider OAuth credential has expired.** Re-authenticate the provider connection (e.g. \`pullfrog auth codex\`), then re-trigger the run.`,
154682
+ "",
154683
+ `[Model settings \u2192](${settingsUrl}) \xB7 [Setup docs \u2192](https://docs.pullfrog.com/keys) \xB7 [Ask in Discord \u2192](https://discord.gg/8y96raFg8e)`
154684
+ ].join("\n");
154685
+ }
154104
154686
  return [
154105
- `**Your LLM provider API key was rejected (401).** Rotate the key in your provider dashboard, then update the matching GitHub Actions secret.`,
154687
+ `**Your LLM provider API key was rejected.** Rotate the key in your provider dashboard, then update the matching GitHub Actions secret.`,
154106
154688
  "",
154107
154689
  `[Update repo secret \u2192](${githubSecretsUrl}) \xB7 [Model settings \u2192](${settingsUrl}) \xB7 [Setup docs \u2192](https://docs.pullfrog.com/keys) \xB7 [Ask in Discord \u2192](https://discord.gg/8y96raFg8e)`
154108
154690
  ].join("\n");
154109
154691
  }
154110
154692
 
154111
- // utils/byokFallback.ts
154112
- var FREE_FALLBACK_SLUG = "opencode/big-pickle";
154113
- function selectFallbackModelIfNeeded(input) {
154114
- if (input.proxyModel) return { fallback: false };
154115
- if (!input.resolvedModel) return { fallback: false };
154116
- if (input.resolvedModel === FREE_FALLBACK_SLUG) return { fallback: false };
154117
- if (!input.resolvedModel.includes("/")) return { fallback: false };
154118
- if (input.agentName === "claude") return { fallback: false };
154119
- if (input.authorized.has(input.resolvedModel)) return { fallback: false };
154120
- return {
154121
- fallback: true,
154122
- from: input.resolvedModel,
154123
- to: FREE_FALLBACK_SLUG
154124
- };
154125
- }
154126
-
154127
154693
  // utils/gitAuthServer.ts
154128
154694
  import { randomUUID as randomUUID5 } from "node:crypto";
154129
154695
  import { writeFileSync as writeFileSync12 } from "node:fs";
154130
154696
  import { createServer as createServer3 } from "node:http";
154131
- import { join as join17 } from "node:path";
154697
+ import { join as join19 } from "node:path";
154132
154698
  var REVOKED_TRAP_MS = 6e4;
154133
154699
  function revokeGitHubToken(token) {
154134
154700
  fetch("https://api.github.com/installation/token", {
@@ -154197,7 +154763,7 @@ async function startGitAuthServer(tmpdir3) {
154197
154763
  function writeAskpassScript(code) {
154198
154764
  const scriptId = randomUUID5();
154199
154765
  const scriptName = `askpass-${scriptId}.js`;
154200
- const scriptPath = join17(tmpdir3, scriptName);
154766
+ const scriptPath = join19(tmpdir3, scriptName);
154201
154767
  const content = [
154202
154768
  `#!/usr/bin/env node`,
154203
154769
  `var a=process.argv[2]||"";`,
@@ -154235,7 +154801,7 @@ async function startGitAuthServer(tmpdir3) {
154235
154801
  var core3 = __toESM(require_core(), 1);
154236
154802
  import { createSign } from "node:crypto";
154237
154803
  import { rename, writeFile } from "node:fs/promises";
154238
- import { dirname as dirname3, join as join18 } from "node:path";
154804
+ import { dirname as dirname3, join as join20 } from "node:path";
154239
154805
 
154240
154806
  // node_modules/.pnpm/@octokit+plugin-throttling@11.0.3_@octokit+core@7.0.5/node_modules/@octokit/plugin-throttling/dist-bundle/index.js
154241
154807
  var import_light = __toESM(require_light(), 1);
@@ -157877,6 +158443,7 @@ var Octokit2 = Octokit.plugin(requestLog, legacyRestEndpointMethods, paginateRes
157877
158443
  );
157878
158444
 
157879
158445
  // utils/github.ts
158446
+ var OIDC_AUDIENCE = "pullfrog-api";
157880
158447
  function isObject4(value2) {
157881
158448
  return typeof value2 === "object" && value2 !== null;
157882
158449
  }
@@ -157893,8 +158460,39 @@ var TokenExchangeError = class extends Error {
157893
158460
  this.status = status;
157894
158461
  }
157895
158462
  };
158463
+ async function fetchIdTokenFromStash(creds) {
158464
+ const url4 = new URL(creds.requestUrl);
158465
+ url4.searchParams.set("audience", OIDC_AUDIENCE);
158466
+ const timeoutMs = 3e4;
158467
+ let response;
158468
+ try {
158469
+ response = await fetch(url4, {
158470
+ headers: { Authorization: `Bearer ${creds.requestToken}` },
158471
+ signal: AbortSignal.timeout(timeoutMs)
158472
+ });
158473
+ } catch (error49) {
158474
+ if (error49 instanceof Error && error49.name === "TimeoutError") {
158475
+ throw new Error(`ID token request timed out after ${timeoutMs}ms`);
158476
+ }
158477
+ throw error49;
158478
+ }
158479
+ if (!response.ok) {
158480
+ throw new TokenExchangeError(
158481
+ response.status,
158482
+ `Failed to get ID token: ${response.status} ${response.statusText}`
158483
+ );
158484
+ }
158485
+ const body = await response.json();
158486
+ if (!body.value) {
158487
+ throw new Error("ID token response has no value field");
158488
+ }
158489
+ if (isGitHubActions) {
158490
+ core3.setSecret(body.value);
158491
+ }
158492
+ return body.value;
158493
+ }
157896
158494
  async function acquireTokenViaOIDC(opts) {
157897
- const oidcToken = await core3.getIDToken("pullfrog-api");
158495
+ const oidcToken = opts?.oidc ? await fetchIdTokenFromStash(opts.oidc) : await core3.getIDToken(OIDC_AUDIENCE);
157898
158496
  const repos = [...opts?.repos ?? []];
157899
158497
  const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
157900
158498
  if (targetRepo) {
@@ -158043,14 +158641,15 @@ async function acquireTokenViaGitHubApp(opts) {
158043
158641
  const installationId = await findInstallationId(jwt2, config3.repoOwner, config3.repoName);
158044
158642
  return await createInstallationToken(jwt2, installationId, opts?.permissions);
158045
158643
  }
158644
+ function isTransientTokenError(error49) {
158645
+ if (error49 instanceof TokenExchangeError) return error49.status >= 500 || error49.status === 429;
158646
+ return error49 instanceof Error && (error49.message.includes("timed out") || error49.message.includes("fetch failed") || error49.message.includes("ECONNRESET") || error49.message.includes("ETIMEDOUT"));
158647
+ }
158046
158648
  async function acquireNewToken(opts) {
158047
- if (isOIDCAvailable()) {
158649
+ if (opts?.oidc || isOIDCAvailable()) {
158048
158650
  return await retry(() => acquireTokenViaOIDC(opts), {
158049
158651
  label: "token exchange",
158050
- shouldRetry: (error49) => {
158051
- if (error49 instanceof TokenExchangeError) return error49.status >= 500 || error49.status === 429;
158052
- return error49 instanceof Error && (error49.message.includes("timed out") || error49.message.includes("fetch failed") || error49.message.includes("ECONNRESET") || error49.message.includes("ETIMEDOUT"));
158053
- }
158652
+ shouldRetry: isTransientTokenError
158054
158653
  });
158055
158654
  }
158056
158655
  if (process.env.GITHUB_ACTIONS === "true") {
@@ -158093,14 +158692,14 @@ function getGitHubUsageSummary() {
158093
158692
  }
158094
158693
  async function writeGitHubUsageSummaryToFile(path4) {
158095
158694
  const summary2 = getGitHubUsageSummary();
158096
- const tmpPath = join18(dirname3(path4), `.usage-summary-${process.pid}.tmp`);
158695
+ const tmpPath = join20(dirname3(path4), `.usage-summary-${process.pid}.tmp`);
158097
158696
  await writeFile(tmpPath, JSON.stringify(summary2));
158098
158697
  await rename(tmpPath, path4);
158099
158698
  }
158100
- function createOctokit(token) {
158699
+ function createOctokit(token, refreshAuth) {
158700
+ let currentToken = token;
158101
158701
  const OctokitWithPlugins = Octokit2.plugin(throttling);
158102
158702
  const octokit = new OctokitWithPlugins({
158103
- auth: token,
158104
158703
  throttle: {
158105
158704
  onRateLimit: (_retryAfter, _options, _octokit, retryCount) => {
158106
158705
  return retryCount <= 2;
@@ -158129,6 +158728,8 @@ function createOctokit(token) {
158129
158728
  return response;
158130
158729
  };
158131
158730
  octokit.hook.wrap("request", async (request2, options) => {
158731
+ const sentToken = currentToken;
158732
+ options.headers.authorization = `token ${sentToken}`;
158132
158733
  try {
158133
158734
  const response = await request2(options);
158134
158735
  onResponse(response);
@@ -158137,6 +158738,13 @@ function createOctokit(token) {
158137
158738
  if (isObject4(error49) && "response" in error49 && isObject4(error49.response) && "headers" in error49.response && isObject4(error49.response.headers)) {
158138
158739
  onResponse(error49.response);
158139
158740
  }
158741
+ if (refreshAuth && isObject4(error49) && "status" in error49 && error49.status === 401) {
158742
+ currentToken = await refreshAuth(sentToken);
158743
+ options.headers.authorization = `token ${currentToken}`;
158744
+ const response = await request2(options);
158745
+ onResponse(response);
158746
+ return response;
158747
+ }
158140
158748
  throw error49;
158141
158749
  }
158142
158750
  });
@@ -158335,7 +158943,17 @@ Use \`${t("git")}\` for local git commands (status, log, add, commit, checkout,
158335
158943
  - \`${t("checkout_pr")}\` - checkout a PR branch (fetches and configures push for forks)
158336
158944
  - \`${t("delete_branch")}\` - delete a remote branch (requires push: enabled)
158337
158945
  - \`${t("push_tags")}\` - push tags (requires push: enabled)
158338
-
158946
+ ${ctx.signedCommits ? `
158947
+ #### Signed commits (enabled for this repository)
158948
+
158949
+ This repository requires GitHub-signed commits, which local git commits can never satisfy. This OVERRIDES any other instruction (including mode instructions) to commit via git or push via \`${t("push_branch")}\`:
158950
+ - Do NOT use git commit or \`${t("push_branch")}\` for same-repo branches \u2014 both are blocked. Instead: edit files, then call \`${t("commit_changes")}\` with a commit message. It commits every working-tree change (or a \`files\` subset) directly to the remote branch as a GitHub-signed (Verified) commit. There is no separate push step.
158951
+ - New branches: create locally as usual (git checkout -b); the remote branch is created on the first \`${t("commit_changes")}\` call.
158952
+ - To integrate remote changes (concurrent pushes, base branch): \`${t("git_fetch")}\`, then git merge --no-commit <ref>, resolve conflicts, git add the results, then \`${t("commit_changes")}\` \u2014 it concludes the merge as a signed merge commit.
158953
+ - \`${t("commit_changes")}\` commits EVERY working-tree change by default \u2014 review \`git status\` first and clean up stray artifacts (or pass \`files\`).
158954
+ - cherry-pick/revert: use \`-n\`/\`--no-commit\` so no local commit is created, then \`${t("commit_changes")}\`.
158955
+ - Fork PRs are the exception: signing is impossible there, so commit and push normally (those commits will be unsigned).
158956
+ ` : ""}
158339
158957
  Rules:
158340
158958
  - All code changes must be pushed to a pull request (new or existing) before the run ends. This environment is ephemeral \u2014 unpushed work is lost permanently. \`git status\` must be clean when you finish.
158341
158959
  - Protected branches (default branch) are blocked from direct pushes in restricted mode. Do not use \`git push\` directly \u2014 it will fail without credentials.
@@ -158511,7 +159129,7 @@ function resolveInstructions(ctx) {
158511
159129
 
158512
159130
  // utils/learnings.ts
158513
159131
  import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
158514
- import { dirname as dirname4, join as join19 } from "node:path";
159132
+ import { dirname as dirname4, join as join21 } from "node:path";
158515
159133
 
158516
159134
  // utils/learningsTruncate.ts
158517
159135
  var MAX_LEARNINGS_LENGTH = 1e5;
@@ -158528,7 +159146,7 @@ function truncateAtLineBoundary(body, cap) {
158528
159146
  // utils/learnings.ts
158529
159147
  var LEARNINGS_FILE_NAME = "pullfrog-learnings.md";
158530
159148
  function learningsFilePath(tmpdir3) {
158531
- return join19(tmpdir3, LEARNINGS_FILE_NAME);
159149
+ return join21(tmpdir3, LEARNINGS_FILE_NAME);
158532
159150
  }
158533
159151
  async function seedLearningsFile(params) {
158534
159152
  const path4 = learningsFilePath(params.tmpdir);
@@ -158948,6 +159566,10 @@ function formatTransientErrorSummary(error49, owner) {
158948
159566
  var core7 = __toESM(require_core(), 1);
158949
159567
  import assert2 from "node:assert/strict";
158950
159568
  var mcpTokenValue;
159569
+ var refreshMcpTokenFn;
159570
+ function getMcpTokenRefresh() {
159571
+ return refreshMcpTokenFn;
159572
+ }
158951
159573
  function getJobToken() {
158952
159574
  const inputToken = core7.getInput("token");
158953
159575
  if (inputToken) {
@@ -158999,6 +159621,29 @@ async function resolveTokens(params) {
158999
159621
  `\xBB acquired scoped MCP token (${Object.entries(mcpPermissions).map((e) => e.join(":")).join(", ")})`
159000
159622
  );
159001
159623
  mcpTokenValue = mcpToken;
159624
+ let currentMcpToken = mcpToken;
159625
+ let refreshPromise;
159626
+ refreshMcpTokenFn = (stale) => {
159627
+ assert2(mcpTokenValue, "tokens already disposed");
159628
+ if (stale !== currentMcpToken) {
159629
+ return Promise.resolve(currentMcpToken);
159630
+ }
159631
+ refreshPromise ??= acquireNewToken({
159632
+ permissions: mcpPermissions,
159633
+ oidc: params.oidc ?? void 0
159634
+ }).then((fresh) => {
159635
+ if (isGitHubActions) {
159636
+ core7.setSecret(fresh);
159637
+ }
159638
+ mcpTokenValue = fresh;
159639
+ currentMcpToken = fresh;
159640
+ log.warning("\xBB GitHub rejected the MCP token; re-acquired a fresh scoped MCP token");
159641
+ return fresh;
159642
+ }).finally(() => {
159643
+ refreshPromise = void 0;
159644
+ });
159645
+ return refreshPromise;
159646
+ };
159002
159647
  let disposingRef;
159003
159648
  const dispose = async () => {
159004
159649
  if (disposingRef) {
@@ -159007,9 +159652,10 @@ async function resolveTokens(params) {
159007
159652
  disposingRef = Promise.withResolvers();
159008
159653
  try {
159009
159654
  mcpTokenValue = void 0;
159655
+ refreshMcpTokenFn = void 0;
159010
159656
  await Promise.all([
159011
159657
  revokeGitHubInstallationToken(gitToken),
159012
- revokeGitHubInstallationToken(mcpToken)
159658
+ revokeGitHubInstallationToken(currentMcpToken)
159013
159659
  ]);
159014
159660
  } finally {
159015
159661
  removeSignalHandler();
@@ -159053,7 +159699,7 @@ async function reportErrorToComment(ctx) {
159053
159699
 
159054
159700
  ${ctx.error}` : ctx.error;
159055
159701
  const repoContext = parseRepoContext();
159056
- const octokit = createOctokit(getGitHubInstallationToken());
159702
+ const octokit = createOctokit(getGitHubInstallationToken(), getMcpTokenRefresh());
159057
159703
  const runId = process.env.GITHUB_RUN_ID ? Number.parseInt(process.env.GITHUB_RUN_ID, 10) : void 0;
159058
159704
  const customParts = [];
159059
159705
  if (runId) {
@@ -159067,7 +159713,6 @@ ${ctx.error}` : ctx.error;
159067
159713
  workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : void 0,
159068
159714
  customParts,
159069
159715
  model: ctx.toolState.model,
159070
- fallbackFrom: ctx.toolState.modelFallback?.from,
159071
159716
  oss: ctx.toolState.oss
159072
159717
  });
159073
159718
  const body = `${formattedError}${footer}`;
@@ -159134,18 +159779,15 @@ async function mintProxyKey(ctx) {
159134
159779
  if (error49 instanceof TransientError) throw error49;
159135
159780
  log.warning(`proxy key mint error: ${error49 instanceof Error ? error49.message : String(error49)}`);
159136
159781
  return null;
159137
- } finally {
159138
- delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
159139
- delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
159140
159782
  }
159141
159783
  }
159142
159784
  async function buildProxyTokenHeaders(ctx) {
159143
159785
  if (ctx.oidcCredentials) {
159144
- process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
159145
- process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
159146
- const oidcToken = await core8.getIDToken("pullfrog-api");
159147
- delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
159148
- delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
159786
+ const creds = ctx.oidcCredentials;
159787
+ const oidcToken = await retry(() => fetchIdTokenFromStash(creds), {
159788
+ label: "ID token mint",
159789
+ shouldRetry: isTransientTokenError
159790
+ });
159149
159791
  return { Authorization: `Bearer ${oidcToken}` };
159150
159792
  }
159151
159793
  if (isLocalApiUrl()) {
@@ -159209,7 +159851,7 @@ async function runProxyResolution(ctx) {
159209
159851
 
159210
159852
  // utils/prSummary.ts
159211
159853
  import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
159212
- import { dirname as dirname5, join as join20 } from "node:path";
159854
+ import { dirname as dirname5, join as join22 } from "node:path";
159213
159855
  var SUMMARY_FILE_NAME = "pullfrog-summary.md";
159214
159856
  var SUMMARY_SCAFFOLD = `# PR summary
159215
159857
 
@@ -159219,7 +159861,7 @@ var SUMMARY_SCAFFOLD = `# PR summary
159219
159861
  var MIN_SNAPSHOT_LENGTH = 60;
159220
159862
  var MAX_SNAPSHOT_LENGTH = 32768;
159221
159863
  function summaryFilePath(tmpdir3) {
159222
- return join20(tmpdir3, SUMMARY_FILE_NAME);
159864
+ return join22(tmpdir3, SUMMARY_FILE_NAME);
159223
159865
  }
159224
159866
  async function seedSummaryFile(params) {
159225
159867
  const path4 = summaryFilePath(params.tmpdir);
@@ -159347,6 +159989,7 @@ var defaultSettings = {
159347
159989
  push: "restricted",
159348
159990
  shell: "restricted",
159349
159991
  prApproveEnabled: false,
159992
+ signedCommits: false,
159350
159993
  modeInstructions: {},
159351
159994
  learnings: null,
159352
159995
  learningsHeadings: [],
@@ -159579,7 +160222,7 @@ ${input.errorMessage}
159579
160222
  ].join("\n");
159580
160223
  }
159581
160224
  function formatProviderModelNotFoundSummary(input) {
159582
- 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.
160225
+ return `The configured model is no longer available in OpenCode's catalog. Pick a different model in the Pullfrog console for \`${input.owner}/${input.name}\`, or contact support if this persists.
159583
160226
 
159584
160227
  \`\`\`
159585
160228
  ${input.raw}
@@ -160020,7 +160663,7 @@ async function main() {
160020
160663
  const initialOctokit = createOctokit(jobToken);
160021
160664
  const runContext = await resolveRunContextData({ octokit: initialOctokit, token: jobToken });
160022
160665
  timer.checkpoint("runContextData");
160023
- const tmpdir3 = createTempDirectory();
160666
+ createTempDirectory();
160024
160667
  const opencodeCliPath = await agents.opencode.install();
160025
160668
  captureBaselineModels(opencodeCliPath);
160026
160669
  if (runContext.dbSecrets) {
@@ -160044,12 +160687,12 @@ async function main() {
160044
160687
  if (payload.event.trigger === "pull_request_synchronize") {
160045
160688
  toolState.beforeSha = payload.event.before_sha;
160046
160689
  }
160047
- const tokenRef = __using(_stack2, await resolveTokens({ push: payload.push }), true);
160048
- wipeRunnerLeakSurface();
160049
160690
  const oidcCredentials = process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ? {
160050
160691
  requestUrl: process.env.ACTIONS_ID_TOKEN_REQUEST_URL,
160051
160692
  requestToken: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
160052
160693
  } : null;
160694
+ const tokenRef = __using(_stack2, await resolveTokens({ push: payload.push, oidc: oidcCredentials }), true);
160695
+ wipeRunnerLeakSurface();
160053
160696
  if (payload.shell !== "enabled") {
160054
160697
  delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
160055
160698
  delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
@@ -160062,7 +160705,7 @@ async function main() {
160062
160705
  repo: runContext.repo,
160063
160706
  toolState
160064
160707
  });
160065
- const octokit = createOctokit(tokenRef.mcpToken);
160708
+ const octokit = createOctokit(tokenRef.mcpToken, getMcpTokenRefresh());
160066
160709
  const runInfo = await resolveRun({ octokit });
160067
160710
  let toolContext;
160068
160711
  let progressCallbackDisabled = false;
@@ -160074,13 +160717,13 @@ async function main() {
160074
160717
  if (payload.cwd && process.cwd() !== payload.cwd) {
160075
160718
  process.chdir(payload.cwd);
160076
160719
  }
160077
- const tmpdir4 = createTempDirectory();
160720
+ const tmpdir3 = createTempDirectory();
160078
160721
  const originalBody = payload.event.body;
160079
160722
  const resolvedBody = await resolveBody({
160080
160723
  event: payload.event,
160081
160724
  octokit,
160082
160725
  repo: runContext.repo,
160083
- tmpdir: tmpdir4,
160726
+ tmpdir: tmpdir3,
160084
160727
  githubToken: tokenRef.mcpToken
160085
160728
  });
160086
160729
  if (resolvedBody !== originalBody) {
@@ -160089,33 +160732,18 @@ async function main() {
160089
160732
  payload.prompt = payload.prompt.replace(originalBody, resolvedBody ?? "");
160090
160733
  }
160091
160734
  }
160092
- const gitAuthServer = __using(_stack, await startGitAuthServer(tmpdir4), true);
160735
+ const gitAuthServer = __using(_stack, await startGitAuthServer(tmpdir3), true);
160093
160736
  setGitAuthServer(gitAuthServer);
160094
- const initialResolvedModel = payload.proxyModel ? void 0 : resolveModel({ slug: payload.model });
160095
- const authorized2 = getAuthorizedModels();
160096
- const fallback = selectFallbackModelIfNeeded({
160097
- resolvedModel: initialResolvedModel,
160098
- proxyModel: payload.proxyModel,
160099
- authorized: authorized2,
160100
- agentName: resolveAgent({ model: initialResolvedModel }).name
160101
- });
160102
- const effectiveSlug = fallback.fallback ? fallback.to : payload.model;
160103
- const resolvedModel = fallback.fallback ? fallback.to : initialResolvedModel;
160104
- if (fallback.fallback) {
160105
- log.warning(
160106
- `\xBB fell back from ${fallback.from} to ${fallback.to} \u2014 no BYOK key present in runner env. add a provider key in repo secrets to use ${fallback.from} instead.`
160107
- );
160108
- toolState.modelFallback = { from: fallback.from };
160109
- }
160737
+ const resolvedModel = payload.proxyModel ? void 0 : resolveModel({ slug: payload.model });
160110
160738
  vertexCredentials = materializeVertexCredentials({ model: resolvedModel });
160111
160739
  const agent2 = resolveAgent({ model: resolvedModel });
160112
- const effectiveModel = payload.proxyModel ?? resolvedModel ?? effectiveSlug;
160740
+ const effectiveModel = payload.proxyModel ?? resolvedModel ?? payload.model;
160113
160741
  toolState.model = effectiveModel;
160114
- if (!fallback.fallback && !payload.proxyModel) {
160742
+ if (!payload.proxyModel) {
160115
160743
  validateAgentApiKey({
160116
160744
  agent: agent2,
160117
160745
  model: effectiveModel,
160118
- authorized: authorized2,
160746
+ authorized: getAuthorizedModels(),
160119
160747
  owner: runContext.repo.owner,
160120
160748
  name: runContext.repo.name
160121
160749
  });
@@ -160132,7 +160760,7 @@ async function main() {
160132
160760
  timer.checkpoint("git");
160133
160761
  const pmSpec = await resolvePackageManagerSpec(process.cwd());
160134
160762
  if (pmSpec) {
160135
- await ensurePackageManager({ spec: pmSpec, binDir: packageManagerBinDir(tmpdir4) });
160763
+ await ensurePackageManager({ spec: pmSpec, binDir: packageManagerBinDir(tmpdir3) });
160136
160764
  }
160137
160765
  timer.checkpoint("packageManager");
160138
160766
  const setupHook = await executeLifecycleHook({
@@ -160145,26 +160773,34 @@ async function main() {
160145
160773
  }
160146
160774
  timer.checkpoint("lifecycleHooks::setup");
160147
160775
  const agentId = agent2.name;
160148
- const modes2 = [...computeModes(agentId), ...runContext.repoSettings.modes];
160776
+ const modes2 = [
160777
+ ...computeModes(agentId, runContext.repoSettings.signedCommits),
160778
+ ...runContext.repoSettings.modes
160779
+ ];
160149
160780
  const outputSchema = resolveOutputSchema();
160150
160781
  toolContext = {
160151
160782
  agentId,
160152
160783
  repo: runContext.repo,
160153
160784
  payload,
160154
160785
  octokit,
160155
- githubInstallationToken: tokenRef.mcpToken,
160786
+ // live getter so raw-token consumers (asset fetches, plan/summary-comment
160787
+ // GETs) see the refreshed MCP token after a mid-run re-acquisition (#891)
160788
+ get githubInstallationToken() {
160789
+ return getGitHubInstallationToken();
160790
+ },
160156
160791
  gitToken: tokenRef.gitToken,
160157
160792
  apiToken: runContext.apiToken,
160158
160793
  modes: modes2,
160159
160794
  postCheckoutScript: runContext.repoSettings.postCheckoutScript,
160160
160795
  prepushScript: runContext.repoSettings.prepushScript,
160161
160796
  prApproveEnabled: runContext.repoSettings.prApproveEnabled,
160797
+ signedCommits: runContext.repoSettings.signedCommits,
160162
160798
  modeInstructions: runContext.repoSettings.modeInstructions,
160163
160799
  toolState,
160164
160800
  runId: runInfo.runId,
160165
160801
  jobId: runInfo.jobId,
160166
160802
  mcpServerUrl: "",
160167
- tmpdir: tmpdir4,
160803
+ tmpdir: tmpdir3,
160168
160804
  oss: runContext.oss,
160169
160805
  plan: runContext.plan,
160170
160806
  resolvedModel
@@ -160175,7 +160811,7 @@ async function main() {
160175
160811
  timer.checkpoint("mcpServer");
160176
160812
  try {
160177
160813
  const learningsPath = await seedLearningsFile({
160178
- tmpdir: tmpdir4,
160814
+ tmpdir: tmpdir3,
160179
160815
  current: runContext.repoSettings.learnings
160180
160816
  });
160181
160817
  toolState.learningsFilePath = learningsPath;
@@ -160192,7 +160828,7 @@ async function main() {
160192
160828
  }
160193
160829
  if (payload.generateSummary && payload.event.is_pr && payload.event.issue_number) {
160194
160830
  const previousSnapshot = await fetchPreviousSnapshot(toolContext, payload.event.issue_number);
160195
- const filePath = await seedSummaryFile({ tmpdir: tmpdir4, previousSnapshot });
160831
+ const filePath = await seedSummaryFile({ tmpdir: tmpdir3, previousSnapshot });
160196
160832
  toolState.summaryFilePath = filePath;
160197
160833
  try {
160198
160834
  toolState.summarySeed = await readFile5(filePath, "utf8");
@@ -160212,6 +160848,7 @@ async function main() {
160212
160848
  modes: modes2,
160213
160849
  agentId,
160214
160850
  outputSchema,
160851
+ signedCommits: runContext.repoSettings.signedCommits,
160215
160852
  learningsFilePath: toolState.learningsFilePath ?? null,
160216
160853
  learningsHeadings: runContext.repoSettings.learningsHeadings,
160217
160854
  setupHookFailure: describeSetupFailure(setupHook.failure)
@@ -160230,7 +160867,7 @@ ${instructions.user}` : null,
160230
160867
  log.info(instructions.full);
160231
160868
  });
160232
160869
  if (agentId === "opencode") {
160233
- const pluginDir = join21(process.cwd(), ".opencode", "plugin");
160870
+ const pluginDir = join23(process.cwd(), ".opencode", "plugin");
160234
160871
  const hasPlugins = existsSync8(pluginDir) && readdirSync2(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
160235
160872
  if (hasPlugins && toolState.dependencyInstallation?.promise) {
160236
160873
  log.info(
@@ -160285,7 +160922,7 @@ ${instructions.user}` : null,
160285
160922
  payload,
160286
160923
  resolvedModel,
160287
160924
  mcpServerUrl: mcpHttpServer.url,
160288
- tmpdir: tmpdir4,
160925
+ tmpdir: tmpdir3,
160289
160926
  // PULLFROG_DATA_DIR (/var/lib/pullfrog) holds codex auth.json + any
160290
160927
  // future pullfrog-managed on-disk secrets. bash via MCP tmpfs-overlays
160291
160928
  // it; agent native FS tools deny it via the same secretDenyPaths plumbing