Compare commits

...

30 Commits

Author SHA1 Message Date
Colin McDonnell cd9c3382c7 fix: remove unused variable in yes test, regenerate prisma client
Made-with: Cursor
2026-03-31 05:53:01 +00:00
Colin McDonnell ba1966f17c feat: encrypted account-level secrets (#501)
* feat: add encrypted account-level secrets with UI for adding API keys

Adds AccountSecret model with AES-256-GCM encryption, API routes for
CRUD, "Add secret" button in model costs section, and injects decrypted
secrets into action env (YAML secrets take precedence).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: repo secrets, sidebar icons, lazy learnings history

- add repo-level secrets with inheritance from org secrets
- add icons to console sidebar sections
- fix learnings history modal: lazy fetch with hover prefetch,
  strip content from list response, load content per-expansion

Made-with: Cursor

* add input validation bounds for secrets and fix client-side name filter

Made-with: Cursor

* refactor: migrate all client-side data fetching to TanStack Query

Replace manual useState/useEffect/fetch patterns and the custom
usePolling hook with useQuery, useInfiniteQuery, and useMutation
across the entire frontend for consistent caching, background
refetching, and reactive invalidation.

- ActiveWorkflowRunsSection: useQuery + refetchInterval
- WorkflowRunHistory: useInfiniteQuery + polling query
- LearningsSection: useQuery per revision (lazy)
- FlagsSettings: self-contained useQuery + useMutation
- SecretsCard: useMutation for delete
- AddWorkflowButton, VerifyWorkflowButton: useMutation
- EmailSignupForm, email-waitlist: useMutation
- providers.tsx: enable refetchOnWindowFocus
- Delete usePolling.ts (no remaining consumers)

Made-with: Cursor

* address PR review: squash migrations, rename accountSecrets → dbSecrets

Squash the two separate secrets migrations into a single migration.
Rename the wire format field from accountSecrets to dbSecrets since
it now carries merged account + repo secrets.

Made-with: Cursor

* fix: update proxyKeys.ts imports after cache.ts -> yes package migration

Made-with: Cursor

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 05:02:38 +00:00
Colin McDonnell 0055aef618 feat: add Claude Code agent for Anthropic model users (#502)
* feat: add Claude Code agent for Anthropic model users

Re-adds Claude Code support (removed in #478) so users with Anthropic API
keys or Claude Code OAuth tokens can use their Claude subscriptions directly.

When an Anthropic model is selected and Claude Code credentials are available,
the system auto-selects the Claude agent instead of OpenCode. The harness
mirrors opentoad's security model: native Bash blocked via --disallowedTools,
MCP ShellTool for restricted shell, ASKPASS for git auth. Includes NDJSON
streaming, provider error detection, cache/cost tracking, browser skill,
and todo progress tracking.

Key changes:
- action/agents/claude.ts: full Claude Code harness
- action/utils/agent.ts: auto-select Claude for anthropic/* models
- action/utils/providerErrors.ts: extracted shared provider error detection
- action/utils/skills.ts: extracted shared skill installation (agent-aware)
- action/models.ts: add CLAUDE_CODE_OAUTH_TOKEN to anthropic envVars
- action/utils/docker.ts: add CLAUDE_CODE_OAUTH_TOKEN to test env allowlist
- CI: add claude to test matrix, pass CLAUDE_CODE_OAUTH_TOKEN secret

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove unused toolId variable, fix apiKeys test env cleanup

The apiKeys test cleanup stripped *_API_KEY vars but missed
CLAUDE_CODE_OAUTH_TOKEN which doesn't match that pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: strip provider prefix from PULLFROG_MODEL in Claude agent

the env override path was returning the raw value (e.g.
"anthropic/claude-sonnet-4-5") without stripping the provider prefix,
causing the Claude CLI to receive an invalid model ID.

Made-with: Cursor

* fix: remove dead cliPath field, add CLAUDE_CODE_OAUTH_TOKEN to workflows

remove unused cliPath from Claude agent RunParams, and pass
CLAUDE_CODE_OAUTH_TOKEN through all pullfrog.yml workflow templates
so users with Claude Pro/Team subscriptions can use their membership.

Made-with: Cursor

* fix: block Bash subagent in Claude Code disallowedTools

Made-with: Cursor

* chore: update model snapshot (opencode/openrouter latest → qwen3.6-plus-free)

Made-with: Cursor

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:29:54 +00:00
Colin McDonnell 9f566d20e4 fix proxy key usage tracking and add OSS spend reporting (#500)
* fix proxy key usage tracking and add OSS spend reporting

replace ProxyKey.disabled with finalizedAt to fix a race where keys
were disabled before their usage was synced, causing all HWM values to
be zero. retireKey now fetches final usage from OpenRouter and records
it atomically with optimistic concurrency. syncAccountUsage skips keys
that fail to fetch rather than recording false zeros.

other fixes:
- wrap OpenRouter API calls in retry logic (exponential backoff)
- reconcileStaleWorkflowRuns now retires proxy keys for completed runs
- subprocess activity timeout only tracks stdout (stderr retry loops
  no longer prevent hung agent detection)
- add oss-spend script (single bulk fetch from OpenRouter) and
  backfill-proxy-key-usage script

Made-with: Cursor

* address review: move isActiveKey check inside transaction, remove redundant guard

Made-with: Cursor
2026-03-31 02:18:08 +00:00
Colin McDonnell 6c5d228c04 allow external_directory reads — not a security boundary
Made-with: Cursor
2026-03-30 21:37:36 +00:00
Colin McDonnell 51659fee71 drop inkeep/agents from oss program, bump action to 0.0.184
Made-with: Cursor
2026-03-30 16:15:57 +00:00
Colin McDonnell bf68e0d915 fix: add blank line before footer divider to fix rendering after details
GitHub markdown needs a blank line between </details> and subsequent
HTML elements. Without it, the footer renders inside the collapsed
section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:58:18 +00:00
Mateusz Burzyński a7b8dcbced Rework incremental diffing (#499)
* Improve our deepening logic

* Use consistent SHA for PR-related operations in CheckoutPrTool

* compute `deepenDepth` at more appropriate time

* fix stale comment

* add comments for `alreadyOnBranch`

* ensure before sha is available

* small cleanup

* computeIncrementalDiff

* move the util

* improve algorithm

* improve algorithm further

* get rid of temp result array

* add comment

* compute incremental diff and updte instructions

* add comment

* update stale comment

* get rid of redundant rev-parse call

* improve comment

* strenghten the instructions

* make diff paths unique
2026-03-27 16:09:13 +00:00
Colin McDonnell 248d11d73d opentoad->pullfrog 2026-03-26 05:10:22 +00:00
Colin McDonnell cb8e33360c feat: add prepush lifecycle hook (#498)
* feat: add prepush lifecycle hook

Add `prepushScript` configuration — an optional shell script that runs
automatically before pushing code to the remote repository. Reuses the
existing `executeLifecycleHook` infrastructure (bash execution, 2-min
timeout, error propagation on non-zero exit). When unconfigured the
hook is a no-op.

Made-with: Cursor

* fix: add prepushScript to run-context API response, fix UI separator

Include prepushScript in the settings returned by the run-context
endpoint so the hook actually fires in production. Also fix the
separator pattern in AgentSettings to match the existing convention
(spacer + hr + spacer instead of margin).

Made-with: Cursor
2026-03-26 04:28:24 +00:00
Colin McDonnell 7454e66533 fix import ordering in opentoad agent
Made-with: Cursor
2026-03-25 22:50:52 +00:00
Mateusz Burzyński c0f6f9ef2a Browser skill (#485)
* Add `BrowserTool`

* add some logging

* go with npm install -g

* remove dep changes since the switch to npm install -g

* tweak

* tweak

* tweak

* tweak

* tweak timeout

* tweak

* remove logs

* skill investigation doc

* wip

* wip

* tweak

* lock agent-browser version

* tweak

* logs

* logs

* more logs

* more debug stuff

* try this

* try this

* try this

* fix PATH

* try this

* tweak

* tweak

* tweak

* update wiki entries

* update wiki once again

* lint fix

---------

Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
2026-03-25 19:36:13 +00:00
David Blass 6b18b6730b live todo tracking, collapsible task list in final progress, hide set_output outside standalone (#492)
* fix false "without reporting progress" error + live todo tracking

clean up orphaned progress comments when review is skipped or only
set_output is used, preventing the false positive in handleAgentResult.

parse todowrite events from OpenCode's NDJSON stream and render a
live markdown checklist in the PR progress comment (2s debounce).
agent's explicit report_progress always takes priority.

Made-with: Cursor

* fix contradictory review/progress prompting

align Review and IncrementalReview mode prompts with their guidance —
mode prompts said "always submit" while guidance said "skip if clean."
remove the empty-approval submission that was silently dropped by the
tool. make progress comment lifecycle explicit: created on first call,
updated in place, removed after review submission.

Made-with: Cursor

* centralize todo tracking into shared TodoTracker module

extract inline todo tracking logic (~95 lines) from opentoad.ts into
action/utils/todoTracking.ts. the tracker is created once in main.ts
and passed to agents via AgentRunContext.todoTracker, making it
agent-agnostic and reusable for future agent implementations.

Made-with: Cursor

* fix todoTracker optional type to match file convention

add | undefined to todoTracker in AgentRunContext, matching every
other optional property in the same interface.

Made-with: Cursor

* instruct agents to always maintain a task list for live progress

system prompt now tells agents to create an internal task list at
the start of every run. the tracker renders it to the progress
comment automatically. report_progress is reserved for final
results only — no more intermediate "Checking..." messages that
cancel the tracker and leave stale text on the comment.

Made-with: Cursor

* require report_progress summary at end of every run

agents must always call report_progress with a final summary —
the completed task list should never be the end state of the
progress comment. updated all review mode prompts to call
report_progress after submitting (or not submitting) a review.

Made-with: Cursor

* keep progress comment after review with final summary

stop deleting the progress comment after review submission —
the agent now always calls report_progress with a summary at
the end, and that summary should persist as a record of what
was done.

Made-with: Cursor

* harden stranded progress comment cleanup

- main.ts: detect when tracker was last writer (agent never called
  report_progress) and delete the stranded checklist instead of
  leaving it as the final comment state
- postCleanup.ts: expand stuck-comment detection to also catch
  stranded todo checklists (regex match for checklist patterns)
  when the process is killed before normal cleanup runs
- modes.ts + selectMode.ts: add report_progress step to Summarize
  and SummaryUpdate modes (only modes that were missing it)

Made-with: Cursor

* fix stale comments, typo, and build mode redundancy

- comment.ts: update deleteProgressComment docstring and inline comment
  to reflect current usage (stranded-comment cleanup, not post-review)
- modes.ts: merge duplicate report_progress steps (8 + 10) into single
  step 9, fix "optimizatfixons" typo
- wiki/post-cleanup.md: document checklist detection regex

Made-with: Cursor

* collapsible completed todos in final progress, hide set_output outside standalone mode

- add renderCollapsible() to TodoTracker, append completed task list as
  <details> section when agent calls report_progress
- cancel tracker after agent's final report_progress so it doesn't
  overwrite with raw checklist
- conditionally register SetOutputTool only in standalone mode or when
  output_schema is provided
- remove unconditional set_output instruction from orchestrator task section
- update Summarize/SummaryUpdate mode guidance to not reference set_output

Made-with: Cursor

* show completion count in collapsible task list summary

Made-with: Cursor

* only count completed (not cancelled) in collapsible task list summary

Made-with: Cursor

* reinforce concise summary prompting across system prompt, modes, and tool description

Made-with: Cursor

* address review feedback: wasUpdated bypass, tracker false-positive, race condition

- remove wasUpdated=true from cleanup paths so handleAgentResult correctly
  detects genuinely silent runs
- add hadProgressComment to ToolState as immutable snapshot for the safety check
- use todoTracker.hasPublished instead of enabled for stranded-comment cleanup
- serialize onUpdate calls via inflightPromise chain with post-cancel guard
- add settled() to wait for in-flight updates before writing final summary

Made-with: Cursor

* address round-2 review: hasPublished after success, finalSummaryWritten flag

- set hasPublished only after onUpdate resolves (not before) so failed
  writes are not counted as published
- add finalSummaryWritten flag to ToolState, set after successful
  non-plan reportProgress; decouple cleanup detection from
  todoTracker.enabled so it survives API failures where cancel() ran
  but the write didn't succeed

Made-with: Cursor
2026-03-25 19:35:31 +00:00
Colin McDonnell e9ce67fec6 remove hardcoded OpenRouter key fallback from onboarding card
OpenRouter is a separate model specifier, not an alternative key for
direct providers. Also skip the "pass it through in pullfrog.yml"
instruction when the key is already in the default workflow template.

Made-with: Cursor
2026-03-25 19:22:00 +00:00
David Blass 64f2238316 Repo Intelligence: agent-managed per-repo learnings with revision history (#487)
* add repo learnings feature with edit history

introduces a new "Learnings" section in the repo console where agents can
persist operational knowledge (setup steps, test commands, conventions) at
the end of runs via an MCP tool. users can also edit learnings manually.

- add `learnings` field to Repo model and `LearningsRevision` audit table
- add `update_learnings` MCP tool for agents to persist repo knowledge
- integrate learnings into prompt assembly as REPO LEARNINGS section
- add learnings step to mode guidance (Build, AddressReviews, Plan, Fix, Task)
- add PATCH /api/repo/[owner]/[repo]/learnings endpoint (JWT auth)
- add GET /api/repo/[owner]/[repo]/learnings/history endpoint (Clerk auth)
- add LearningsSection component with textarea, save-on-blur, and history modal
- record revision history with actor tracking (agent vs user) and pruning (50 max)
- gate UI behind owner === "pullfrog" for internal dogfooding

Made-with: Cursor

* fix prisma enum import path for LearningsActor

Made-with: Cursor

* simplify learnings schema: remove LearningsActor enum, store model name directly

the actor/actorName split was unnecessary — learnings are only written by
agents so the revision table just needs a model column. removes all user
editing concepts from schema, API, and frontend.

Made-with: Cursor

* fix migration: add separate migration instead of rewriting existing one

restores original learnings_revisions migration and adds a new migration
that drops actor/actorName columns, backfills model from actorName, and
drops the LearningsActor enum.

Made-with: Cursor

* polish learnings feature: rename to Repo Intelligence, fix atomicity, fix review skip

- rename user-facing "learnings" to "Repo Intelligence" (UI, prompt section, wiki, sidebar)
- simplify description to "Automatically discovered by the agent across runs."
- wrap repo.update + revision create in $transaction for atomicity
- refactor recordLearningsRevision to pruneLearningsRevisions (prune-only)
- fix empty review skip: don't block APPROVE reviews with no body
- fix broken docs anchor: #free-options → #free-models
- update agent guidance to require flat bullet list format with pruning
- add accessibility: aria-expanded, sr-only loading, output element
- add chevron rotation, stale data clear on modal close, max-h scroll
- trim + length-limit model field, remove type cast, restore pre-existing comment
- update wiki prompt examples with actual bullet-formatted content
- update model test snapshot

Made-with: Cursor

* fix stale free model name in docs, rename utility file to match export

- docs/keys.mdx: MiMo V2 Flash → MiMo V2 Pro (matches model code change)
- rename recordLearningsRevision.ts → pruneLearningsRevisions.ts

Made-with: Cursor

* Add skill, .neon

* polish learnings UI and remove verbose log

- learnings code block: read-only appearance with muted text, copy button, rounded corners
- history modal: full-width rows with cursor-pointer, chevron moved to right, no preview text
- drop noisy update_learnings log line

Made-with: Cursor

* inject learningsStep into all modes, drop seed script, soften revision styling

Made-with: Cursor

* Drop seed

---------

Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
2026-03-25 19:15:43 +00:00
Colin McDonnell e6d34ee01b add OpenRouter proxy for managed model routing and OSS program (#488)
* add OpenRouter proxy for managed model routing and OSS program

proxy layer that mints ephemeral OpenRouter keys for users without BYOK
API keys. two paths: pro plan users get their selected model proxied via
OpenRouter; OSS program repos (hard-coded allowlist in config.ts) get
free Claude Opus. BYOK env vars (PULLFROG_MODEL/OPENCODE_MODEL) always
take precedence.

frontend: OSS repos see a static "Opus (Free)" badge with the model
dropdown disabled and no API key requirement. all models now carry
openRouterResolve metadata for proxy target resolution.

Made-with: Cursor

* implement OSS program: proxy infrastructure for free model credits

server-side OSS allowlist determines eligible repos. action mints
ephemeral OpenRouter keys via OIDC-authenticated /api/proxy-token
endpoint (idempotent on runId, $10 per-key safety limit). keys are
disabled on workflow completion when no running refs remain. HWM-based
usage sync tracks cumulative spend per account.

schema: ProxyKey model, Account.usageUsd/activeKeyId, WorkflowRun.proxyKeyId
action: OIDC credential stashing, resolveProxyModel uses server oss flag
frontend: isOss flows from server page to components (no client allowlist)
Made-with: Cursor

* address PR review: repo cross-check, key retirement lifecycle, dead code removal

- proxy-token: verify runId belongs to OIDC-authenticated repo via repo relation
- add retireKey() shared primitive: disable in OpenRouter first, then mark disabled in DB
- rotateKey: retire old active key after swap to prevent orphans
- webhook: replace inline cleanupProxyKey with retireKey calls
- syncAccountUsage: skip disabled keys
- remove vestigial AccountPlan/plan field from action types
- add disabled field to ProxyKey schema + migration

Made-with: Cursor

* replace deprecated opencode/mimo-v2-flash-free with mimo-v2-pro-free

Made-with: Cursor

* fix migration ordering: rename disabled migration to sort after table creation

Made-with: Cursor

* squash proxy key migrations into single migration

Made-with: Cursor

* add preview repo to OSS allowlist for testing

Made-with: Cursor

* populate OSS allowlist from oss-program-invitees.json

Made-with: Cursor

* format oss-program-invitees.json

Made-with: Cursor

* add installed public repos to OSS allowlist

split ossRepos into three provenance-tracked lists:
- internalRepos (pullfrog, colinhacks, RobinTail)
- installedPublicRepos (external public non-fork repos with active installs)
- invitees (from oss-program-invitees.json)

also adds scripts/list-oss-candidates.ts to regenerate the installed list

Made-with: Cursor

* fix: resolve tokens before clearing OIDC env vars

resolveTokens → acquireNewToken → isOIDCAvailable() checks
ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN env vars. The new OIDC
stashing code was deleting them in restricted shell mode before
resolveTokens ran, causing it to fall through to the GitHub App
path which requires GITHUB_APP_ID/GITHUB_PRIVATE_KEY.

Made-with: Cursor

* derive proxy-token auth from OIDC claims, add ensureWorkflowRun upsert

- proxy-token no longer requires body.runId; uses claims.run_id + claims.repository
- shared ensureWorkflowRun upsert called from both webhook and proxy-token
- workflow_run_requested handler now eagerly creates WorkflowRun records
- eliminates race condition between webhook and action proxy-token call

Made-with: Cursor

* hardcode PULLFROG_ACCOUNT_ID, document preview debugging lessons

GitHub node IDs are constant — no reason for this to be an env var.
Removes the trailing-newline bug that caused P2025 errors.
Adds wiki docs on workflow testing, Vercel env gotchas, and Neon
preview branch discovery.

Made-with: Cursor

* fix: parse OpenRouter create-key response correctly

the API returns `key` at the top level, not inside `data`

Made-with: Cursor

* onboarding cards, unlock OSS model selection, simplify console

- add OnboardingCard component with two states: workflow install
  and model+test (dispatches "Tell me a joke" for test run)
- replace PromptBox overlay gates with dedicated onboarding cards;
  PromptBox is now just the form, always enabled
- use hasWorkflowRuns DB check to decide onboarding vs promptbox
- unlock ModelSelector for OSS repos (was locked to Opus badge);
  resolve proxyModel from repo's selected model alias in run-context
- rename "API key" row to "Model costs" with pure client-side states:
  OSS covered, auto-resolve, free model, BYOK with env var names
- add "(Recommended)" badge to model aliases with recommended: true
- remove OSS_MODEL_DISPLAY_NAME constant and secrets-fetching logic

Made-with: Cursor

* update stale xai model snapshot

Made-with: Cursor

* rename Permissions to Security, hide git push toggle, add shell isolation toggle with disabled state for public repos, remove opentoad agent name from logs

Made-with: Cursor

* chevron hover states, sidebar hooks/security entries

Made-with: Cursor

* address PR review feedback: rename recommended→preferred, fix dispatch orphan, update wiki docs

- rename `recommended` to `preferred` in model alias registry to distinguish
  from the UI "Recommended" badge (which is hardcoded for opus + codex only)
- cancel precreated WorkflowRun when workflow lookup fails in dispatch-workflow
- replace run_sql/vercel env pull in wiki docs with neonctl + Prisma pattern
- extend scripts/neon-branch.ts to output DATABASE_URL via neonctl
- add Recommended badge to GPT Codex alongside Claude Opus in ModelSelector

Made-with: Cursor
2026-03-25 17:19:55 +00:00
Colin McDonnell 39525547b5 add in-memory trigger dedup with Zod-validated search params (#496)
* add in-memory trigger dedup with Zod-validated search params

replace scattered manual validation (isValidAction, required review_id/comment_id checks, silent action default) with a discriminated union Zod schema. dedup double-clicks via a module-level Map with 30s TTL — no migration, no new table.

Made-with: Cursor

* update model snapshot (xai latest → grok-4.20-multi-agent-0309)

Made-with: Cursor
2026-03-24 18:57:42 +00:00
Colin McDonnell 3ff11f97eb replace deprecated opencode/mimo-v2-flash-free with mimo-v2-pro-free, update model snapshot
Made-with: Cursor
2026-03-21 16:31:45 +00:00
Colin McDonnell b31800c213 clarify PR summary instructions for readable section titles
Made-with: Cursor
2026-03-20 16:17:59 +00:00
Colin McDonnell 3a1ffde545 update model snapshot (opencode latest → gpt-5.4-nano)
Made-with: Cursor
2026-03-18 19:13:04 +00:00
Mateusz Burzyński cccf1775d6 Update actions/setup-node 2026-03-18 12:55:52 +00:00
Colin McDonnell 026cc7a276 skip empty review submissions instead of posting noise
when create_pull_request_review is called with no body and no inline
comments, return early with a clear log message instead of hitting
GitHub's 422 or posting a useless "approved" comment.

Made-with: Cursor
2026-03-17 20:09:33 +00:00
Colin McDonnell c6a3ee0e9a show model name in footer, drop pullfrog.com link (#484)
* show model name in footer, drop pullfrog.com link

add model slug to buildPullfrogFooter so every Pullfrog comment
displays the active model (e.g. "Using `Big Pickle` (free)" or
"Using `Claude Opus`"). remove the pullfrog.com link from all footers.

Made-with: Cursor

* reject <br/> tags in comment bodies, add prompt guidance

add runtime validation in addFooter that throws if <br/> is followed
by a non-blank line (breaks GitHub heading rendering). the agent sees
the error and retries with clean markdown. also update Summarize mode
prompt to explicitly forbid <br/> tags.

Made-with: Cursor

* fix <br/> guidance: move to event instructions, clarify blank line rule

the formatting rule belongs in DEFAULT_PR_SUMMARY_INSTRUCTIONS (event
instructions), not the Summarize mode prompt. clarify that <br/> must
always be followed by a blank line before headings.

Made-with: Cursor

* generalize block-level HTML rule in summary instructions

add a prominent top-level rule about requiring blank lines between ALL
block-level HTML elements and markdown syntax, not just <br/>.

Made-with: Cursor

* move model to toolState instead of threading through params

model is set once at startup and read everywhere — it belongs on
toolState, not threaded as a separate param through 8 call sites.
postCleanup runs without toolState so it just omits the model label.

Made-with: Cursor

* update models.dev snapshot (openai latest changed)

Made-with: Cursor

* add comment to models snapshot test explaining its purpose

Made-with: Cursor
2026-03-17 19:59:11 +00:00
Colin McDonnell 30d68e53a7 fix: skip API key validation for free opencode models
free models (big-pickle, gpt-5-nano, etc.) define envVars: [] and
isFree: true but validateAgentApiKey always required at least one
provider key. now the validation is model-aware: free models bypass
the check, keyed models validate their specific vars, and auto-select
still requires at least one known key.

closes #483

Made-with: Cursor
2026-03-16 20:48:59 +00:00
Colin McDonnell 8a734c32f4 fix workflow detection, duplicate summaries, review resilience (#482)
* fix workflow detection when repos have many workflows

Switch workflow lookup to GitHub's direct workflow-by-filename API so pullfrog.yml is found even when list endpoints paginate, and paginate installation scans in maintenance scripts to avoid partial coverage.

Made-with: Cursor

* fix review comment line resolution: pre-validate against diff hunks + auto-bisect fallback

when submitting a review with inline comments, the tool now:
1. fetches the PR diff and validates each comment's line range against the actual hunk boundaries
2. moves invalid comments to the review body with a clear explanation
3. on 422 from GitHub (rare API quirks where valid-looking lines are rejected), bisects
   comments using disposable pending reviews to isolate failures
4. retries with only the comments GitHub accepts

also fixes getHttpStatus (previously isStatusError) which wasn't recognizing Octokit errors,
and warns in the tool description that each call creates a permanent visible review.

Made-with: Cursor

* remove bisect fallback, anchor review to checkout sha, make start_line optional

- drop the auto-bisect-on-422 logic entirely; pre-validation catches the
  real issues and the 422 catch now just throws a clear actionable error
- anchor review submission to checkoutSha so line numbers match the diff
  the agent actually analyzed (avoids stale-line 422s from new pushes)
- make start_line optional and only set start_line/start_side when it
  differs from line (single-line comments don't need the range fields)
- improve headMovedDuringReview detection to use latestHeadSha directly

Made-with: Cursor

* drop review comment pre-validation in favor of pinned commit_id

The pre-validation (listFiles + hunk parsing) was checking comments
against the current PR diff, but the review is now anchored to
checkoutSha. When HEAD moves, pre-validation checks the wrong diff
and can false-reject valid comments. GitHub's own commit_id-anchored
validation is the correct source of truth.

Made-with: Cursor

* add logging to fetchExistingSummaryComment for duplicate summary debug

Made-with: Cursor

* fix duplicate summary comments: guard create_issue_comment for existing summaries

When select_mode finds an existing summary comment (existingSummaryCommentId),
create_issue_comment with type: "Summary" now auto-redirects to update instead
of creating a new comment. Belt-and-suspenders for the token fix in selectMode.ts.

Made-with: Cursor

* document api auth patterns to prevent token misuse

add wiki/api-auth.md explaining the two auth patterns (GitHub token vs
Pullfrog JWT) and when to use each. add auth comments to all action-facing
routes and their callers so the correct token is obvious.

Made-with: Cursor

* fix models.dev snapshot: filter beta models, add tiebreaker

Skip models with any status (beta, deprecated) so nightly/experimental
releases don't cause snapshot churn. Add lexicographic tiebreaker for
stable ordering when release dates match.

Made-with: Cursor

* add tests to pre-push hook

Made-with: Cursor

* fix: decouple summary dispatch from re-review gate on pull_request_synchronize

The summary workflow was never dispatched on new commits because the
pull_request_synchronize handler broke early when prReReview was disabled,
before reaching the prSummaryComment check. Now re-review and summary
are dispatched independently.

Made-with: Cursor

* resolve merge conflicts in rebase.md and checkout.ts

Made-with: Cursor

* fix: restore checkout.ts and rebase.md from remote

Made-with: Cursor
2026-03-16 18:12:11 +00:00
Anna Bocharova 2e37fb3dfa fix(test): Updating snapshot. (#480) 2026-03-13 05:57:45 +00:00
Colin McDonnell cbbcb64859 restructure docs: split triggers into usage pages, add model resolution docs
- split triggers.mdx into direct-prompting, pr-reviews, issue-enrichment, coding-tasks
- rename manual-setup.mdx to headless-action.mdx (CI integration)
- reorganize sidebar into Getting started / Usage / Reference groups
- add redirects for /triggers and /manual-setup
- add PULLFROG_MODEL env var support across action, workflows, and docs
- rewrite models.mdx with aliases, free models, resolution chain, routers
- update all cross-references in app, components, and docs

Made-with: Cursor
2026-03-12 17:45:15 +00:00
Colin McDonnell df9598ea5f add free opencode model metadata and improve model picker UX
Made-with: Cursor
2026-03-12 16:32:30 +00:00
Colin McDonnell 250fe7eaa1 fix test token scoping: override GITHUB_TOKEN via OIDC in ensureGitHubToken
the runner's GITHUB_TOKEN (scoped to pullfrog/app) was leaking into
test subprocesses targeting pullfrog/test-repo, causing 400s from the
Pullfrog API on run-context fetches.

instead of deleting GITHUB_TOKEN from the subprocess env,
ensureGitHubToken now always mints a fresh OIDC token scoped to
GITHUB_REPOSITORY when OIDC is available — replacing any inherited
token with a correctly-scoped one.

also adds an informative throw in acquireTokenViaGitHubApp when
GITHUB_APP_ID/GITHUB_PRIVATE_KEY are missing.

Made-with: Cursor
2026-03-12 06:15:01 +00:00
Colin McDonnell 4a8c432a48 add Summarize mode for updatable PR summary comments (#470)
* add Summarize mode for updatable PR summary comments

Introduces a Summarize mode that manages a single summary comment per PR,
updated in place on subsequent pushes. Mirrors the Plan/PlanEdit pattern:
API endpoint for existing-comment lookup at select_mode time, node ID
tracking on WorkflowRun, and SummaryUpdate guidance for edits.

Also fixes summary format instructions: Before/After uses inline <br/>
to avoid double line breaks, metadata line placed after key changes,
SHA-256 anchor instructions strengthened against fabrication.

Made-with: Cursor

* fix pre-existing lint error in checkout.ts

Made-with: Cursor

* fix dead restricted param in deepenForBeforeSha

GitAuthOptions dropped the restricted field in the ASKPASS refactor (#478)
but deepenForBeforeSha (#471) still passed it. Remove the field and the
now-unused shell param from DeepenForBeforeShaParams.

Made-with: Cursor
2026-03-12 05:32:17 +00:00
58 changed files with 27343 additions and 23027 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
+1
View File
@@ -37,6 +37,7 @@ jobs:
# add any additional keys your agent(s) need
# optionally, comment out any you won't use
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
+6 -5
View File
@@ -9,7 +9,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
@@ -27,12 +27,13 @@ jobs:
strategy:
fail-fast: true
matrix:
agent: [opentoad]
agent: [claude, opentoad]
test:
[mcpmerge, nobash, restricted, smoke]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
@@ -41,11 +42,11 @@ jobs:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
PULLFROG_MODEL: ${{ vars.PULLFROG_MODEL }}
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
@@ -79,7 +80,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
+562
View File
@@ -0,0 +1,562 @@
/**
* Claude Code agent — secure harness around the `claude` CLI.
*
* mirrors the opentoad harness's security model:
* - native Bash blocked via --disallowedTools (agent cannot shell out)
* - MCP ShellTool provides restricted shell (filtered env, no secrets)
* - MCP server injected via --mcp-config (not replacing project config)
* - ASKPASS handles git auth separately (token never in subprocess env)
*
* the agent process itself gets full env (needs LLM API keys, PATH, etc.).
* security is enforced at the tool layer, not the process layer.
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { ghPullfrogMcpName } from "../external.ts";
import { resolveModelSlug } from "../models.ts";
import { getIdleMs, markActivity } from "../utils/activity.ts";
import { log } from "../utils/cli.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { detectProviderError } from "../utils/providerErrors.ts";
import { addSkill } from "../utils/skills.ts";
import { spawn } from "../utils/subprocess.ts";
import { ThinkingTimer } from "../utils/timer.ts";
import type { TodoTracker } from "../utils/todoTracking.ts";
import { getDevDependencyVersion } from "../utils/version.ts";
import { type AgentResult, type AgentRunContext, type AgentUsage, agent } from "./shared.ts";
async function installClaudeCli(): Promise<string> {
return await installFromNpmTarball({
packageName: "@anthropic-ai/claude-code",
version: getDevDependencyVersion("@anthropic-ai/claude-code"),
executablePath: "cli.js",
installDependencies: false,
});
}
// ── config ─────────────────────────────────────────────────────────────────────
function writeMcpConfig(ctx: AgentRunContext): string {
const configDir = join(ctx.tmpdir, ".claude");
mkdirSync(configDir, { recursive: true });
const configPath = join(configDir, "mcp.json");
writeFileSync(
configPath,
JSON.stringify({
mcpServers: {
[ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl },
},
})
);
return configPath;
}
// ── model resolution ─────────────────────────────────────────────────────────
function resolveClaudeModel(modelSlug: string | undefined): string | undefined {
// 1. explicit env var override
const envModel = process.env.PULLFROG_MODEL?.trim();
if (envModel) {
const slashIndex = envModel.indexOf("/");
const cliModel = slashIndex > 0 ? envModel.slice(slashIndex + 1) : envModel;
log.info(`» model: ${cliModel} (override via PULLFROG_MODEL)`);
return cliModel;
}
if (!modelSlug) return undefined;
// 2. resolve slug to concrete specifier (e.g. "anthropic/claude-opus" → "anthropic/claude-opus-4-6")
// then strip the "anthropic/" prefix to get the Claude CLI model name
const resolved = resolveModelSlug(modelSlug);
if (resolved) {
const slashIndex = resolved.indexOf("/");
const cliModel = slashIndex > 0 ? resolved.slice(slashIndex + 1) : resolved;
log.info(`» model: ${cliModel} (resolved from ${modelSlug})`);
return cliModel;
}
log.warning(`» unknown model slug "${modelSlug}" — letting Claude Code auto-select`);
return undefined;
}
// ── NDJSON event types ─────────────────────────────────────────────────────────
interface ContentBlock {
type: string;
text?: string;
id?: string;
name?: string;
input?: unknown;
tool_use_id?: string;
content?: string | unknown;
is_error?: boolean;
[key: string]: unknown;
}
interface ClaudeSystemEvent {
type: "system";
[key: string]: unknown;
}
interface ClaudeAssistantEvent {
type: "assistant";
message?: {
role?: string;
content?: ContentBlock[];
model?: string;
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
};
[key: string]: unknown;
};
[key: string]: unknown;
}
interface ClaudeUserEvent {
type: "user";
message?: {
role?: string;
content?: ContentBlock[];
[key: string]: unknown;
};
[key: string]: unknown;
}
interface ClaudeResultEvent {
type: "result";
subtype?: string;
result?: string;
session_id?: string;
num_turns?: number;
total_cost_usd?: number;
total_input_tokens?: number;
total_output_tokens?: number;
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
[key: string]: unknown;
}
// additional event types emitted by Claude CLI (handled as no-ops / debug)
interface ClaudeStreamEvent {
type: "stream_event";
[key: string]: unknown;
}
interface ClaudeToolProgressEvent {
type: "tool_progress";
[key: string]: unknown;
}
interface ClaudeToolUseSummaryEvent {
type: "tool_use_summary";
[key: string]: unknown;
}
interface ClaudeAuthStatusEvent {
type: "auth_status";
[key: string]: unknown;
}
type ClaudeEvent =
| ClaudeSystemEvent
| ClaudeAssistantEvent
| ClaudeUserEvent
| ClaudeResultEvent
| ClaudeStreamEvent
| ClaudeToolProgressEvent
| ClaudeToolUseSummaryEvent
| ClaudeAuthStatusEvent;
// ── runner ──────────────────────────────────────────────────────────────────────
type RunParams = {
label: string;
args: string[];
cwd: string;
env: Record<string, string | undefined>;
todoTracker?: TodoTracker | undefined;
};
async function runClaude(params: RunParams): Promise<AgentResult> {
const startTime = performance.now();
let eventCount = 0;
const thinkingTimer = new ThinkingTimer();
let finalOutput = "";
let accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
let costUsd: number | undefined;
let tokensLogged = false;
function buildUsage(): AgentUsage | undefined {
const totalInput =
accumulatedTokens.input + accumulatedTokens.cacheRead + accumulatedTokens.cacheWrite;
return totalInput > 0 || accumulatedTokens.output > 0
? {
agent: "claude",
inputTokens: totalInput,
outputTokens: accumulatedTokens.output,
cacheReadTokens: accumulatedTokens.cacheRead || undefined,
cacheWriteTokens: accumulatedTokens.cacheWrite || undefined,
costUsd,
}
: undefined;
}
const handlers = {
system: (_event: ClaudeSystemEvent) => {
log.debug(`» ${params.label} system event`);
},
assistant: (event: ClaudeAssistantEvent) => {
const content = event.message?.content;
if (!content) return;
for (const block of content) {
if (block.type === "text" && block.text?.trim()) {
const message = block.text.trim();
log.box(message, { title: params.label });
finalOutput = message;
} else if (block.type === "tool_use") {
const toolName = block.name || "unknown";
thinkingTimer.markToolCall();
log.toolCall({ toolName, input: block.input || {} });
// agent's explicit MCP report_progress takes priority over todo tracking
if (toolName.includes("report_progress") && params.todoTracker) {
log.debug("» report_progress detected, disabling todo tracking");
params.todoTracker.cancel();
}
// parse TodoWrite events for live progress tracking
if (toolName === "TodoWrite" && params.todoTracker?.enabled) {
params.todoTracker.update(block.input);
}
}
}
// accumulate per-message usage if available
const msgUsage = event.message?.usage;
if (msgUsage) {
accumulatedTokens.input += msgUsage.input_tokens || 0;
accumulatedTokens.output += msgUsage.output_tokens || 0;
}
},
user: (event: ClaudeUserEvent) => {
const content = event.message?.content;
if (!content) return;
for (const block of content) {
if (typeof block === "string") continue;
if (block.type === "tool_result") {
thinkingTimer.markToolResult();
const outputContent =
typeof block.content === "string"
? block.content
: Array.isArray(block.content)
? (block.content as unknown[])
.map((entry: unknown) =>
typeof entry === "string"
? entry
: typeof entry === "object" && entry !== null && "text" in entry
? String((entry as { text: unknown }).text)
: JSON.stringify(entry)
)
.join("\n")
: String(block.content);
if (block.is_error) {
log.info(`» tool error: ${outputContent}`);
} else {
log.debug(`» tool output: ${outputContent}`);
}
}
}
},
result: (event: ClaudeResultEvent) => {
const subtype = event.subtype || "unknown";
const numTurns = event.num_turns || 0;
if (subtype === "success") {
// extract detailed usage from result event (most accurate source)
const usage = event.usage;
const inputTokens = usage?.input_tokens || 0;
const cacheRead = usage?.cache_read_input_tokens || 0;
const cacheWrite = usage?.cache_creation_input_tokens || 0;
const outputTokens = usage?.output_tokens || 0;
const totalInput = inputTokens + cacheRead + cacheWrite;
accumulatedTokens = { input: inputTokens, output: outputTokens, cacheRead, cacheWrite };
costUsd = event.total_cost_usd ?? undefined;
log.info(
`» ${params.label} result: subtype=${subtype}, turns=${numTurns}, cost=$${costUsd?.toFixed(4) ?? "?"}`
);
if (!tokensLogged) {
log.table([
[
{ data: "Cost", header: true },
{ data: "Input", header: true },
{ data: "Cache Read", header: true },
{ data: "Cache Write", header: true },
{ data: "Output", header: true },
],
[
`$${costUsd?.toFixed(4) || "0.0000"}`,
String(totalInput),
String(cacheRead),
String(cacheWrite),
String(outputTokens),
],
]);
tokensLogged = true;
}
} else if (subtype === "error_max_turns") {
log.info(`» ${params.label} max turns reached: ${JSON.stringify(event)}`);
} else if (subtype === "error_during_execution") {
log.info(`» ${params.label} execution error: ${JSON.stringify(event)}`);
} else {
log.info(`» ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
}
if (event.result?.trim()) {
finalOutput = event.result.trim();
}
},
// additional Claude CLI event types — debug-logged only
stream_event: () => {},
tool_progress: () => {},
tool_use_summary: () => {},
auth_status: () => {},
};
const recentStderr: string[] = [];
const MAX_STDERR_LINES = 20;
let lastProviderError: string | null = null;
let output = "";
let stdoutBuffer = "";
try {
const result = await spawn({
cmd: "node",
args: params.args,
cwd: params.cwd,
env: params.env,
activityTimeout: 0,
stdio: ["ignore", "pipe", "pipe"],
onStdout: async (chunk) => {
const text = chunk.toString();
output += text;
markActivity();
stdoutBuffer += text;
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const event = JSON.parse(trimmed) as ClaudeEvent;
eventCount++;
log.debug(JSON.stringify(event, null, 2));
const timeSinceLastActivity = getIdleMs();
if (timeSinceLastActivity > 10000) {
log.info(
`» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s (${params.label} may be processing internally) (${eventCount} events processed so far)`
);
}
markActivity();
const handler = handlers[event.type as keyof typeof handlers];
if (handler) {
(handler as (e: ClaudeEvent) => void)(event);
} else {
log.debug(`» ${params.label} event (unhandled): type=${event.type}`);
}
} catch {
log.debug(`» non-JSON stdout line: ${trimmed.substring(0, 200)}`);
}
}
},
onStderr: (chunk) => {
const trimmed = chunk.trim();
if (!trimmed) return;
recentStderr.push(trimmed);
if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
const providerError = detectProviderError(trimmed);
if (providerError) {
lastProviderError = providerError;
log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
} else {
log.debug(trimmed);
}
},
});
if (result.exitCode === 0) {
await params.todoTracker?.flush();
} else {
params.todoTracker?.cancel();
}
const duration = performance.now() - startTime;
log.info(
`» ${params.label} completed in ${Math.round(duration)}ms with exit code ${result.exitCode}`
);
if (eventCount === 0) {
const stderrContext = recentStderr.join("\n");
const diagnosis = lastProviderError
? `provider error: ${lastProviderError}`
: "unknown cause (no stdout events received)";
log.info(`» ${params.label} produced 0 events (${diagnosis})`);
if (stderrContext) log.info(`» last stderr output:\n${stderrContext}`);
}
if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0)) {
const totalTokens = accumulatedTokens.input + accumulatedTokens.output;
log.table([
[
{ data: "Input Tokens", header: true },
{ data: "Output Tokens", header: true },
{ data: "Total Tokens", header: true },
],
[String(accumulatedTokens.input), String(accumulatedTokens.output), String(totalTokens)],
]);
}
const usage = buildUsage();
if (result.exitCode !== 0) {
const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
const errorMessage =
result.stderr ||
result.stdout ||
`unknown error - no output from Claude CLI${errorContext}`;
log.error(
`${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}`
);
log.debug(`stdout: ${result.stdout?.substring(0, 500)}`);
log.debug(`stderr: ${result.stderr?.substring(0, 500)}`);
return { success: false, output: finalOutput || output, error: errorMessage, usage };
}
if (eventCount === 0 && lastProviderError) {
return {
success: false,
output: finalOutput || output,
error: `provider error: ${lastProviderError}`,
usage,
};
}
return { success: true, output: finalOutput || output, usage };
} catch (error) {
params.todoTracker?.cancel();
const duration = performance.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error);
const isActivityTimeout = errorMessage.includes("activity timeout");
const stderrContext = recentStderr.slice(-10).join("\n");
const diagnosis = lastProviderError
? `likely cause: ${lastProviderError}`
: eventCount === 0
? "Claude produced 0 stdout events - check if the API is reachable"
: `${eventCount} events were processed before the hang`;
log.info(
`» ${params.label} ${isActivityTimeout ? "hung" : "failed"} after ${(duration / 1000).toFixed(1)}s: ${errorMessage}`
);
log.info(`» diagnosis: ${diagnosis}`);
if (stderrContext)
log.info(
`» recent stderr (last ${Math.min(recentStderr.length, 10)} lines):\n${stderrContext}`
);
return {
success: false,
output: finalOutput || output,
error: `${errorMessage} [${diagnosis}]`,
usage: buildUsage(),
};
}
}
// ── agent ───────────────────────────────────────────────────────────────────────
export const claude = agent({
name: "claude",
install: installClaudeCli,
run: async (ctx) => {
const cliPath = await installClaudeCli();
const model = ctx.payload.proxyModel ?? resolveClaudeModel(ctx.payload.model);
const homeEnv = {
HOME: ctx.tmpdir,
XDG_CONFIG_HOME: join(ctx.tmpdir, ".config"),
};
mkdirSync(join(homeEnv.XDG_CONFIG_HOME, "claude"), { recursive: true });
const agentBrowserVersion = getDevDependencyVersion("agent-browser");
addSkill({
ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`,
skill: "agent-browser",
env: homeEnv,
agent: "claude",
});
const mcpConfigPath = writeMcpConfig(ctx);
const args = [
cliPath,
"-p",
ctx.instructions.full,
"--output-format",
"stream-json",
"--dangerously-skip-permissions",
"--mcp-config",
mcpConfigPath,
"--verbose",
"--no-session-persistence",
"--disallowedTools",
"Bash",
"Agent(Bash)",
];
if (model) {
args.push("--model", model);
}
// agent process gets full env — needs LLM API keys, PATH, locale, etc.
// security is enforced via --disallowedTools (Bash + Bash subagent) and MCP tool filtering.
const env: Record<string, string | undefined> = {
...process.env,
...homeEnv,
};
const repoDir = process.cwd();
log.debug(`» starting Pullfrog (Claude Code): node ${args.join(" ")}`);
log.debug(`» working directory: ${repoDir}`);
return runClaude({
label: "Pullfrog",
args,
cwd: repoDir,
env,
todoTracker: ctx.todoTracker,
});
},
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { claude } from "./claude.ts";
import { opentoad } from "./opentoad.ts";
import type { Agent } from "./shared.ts";
export type { Agent, AgentUsage } from "./shared.ts";
export const agents = { opentoad } satisfies Record<string, Agent>;
export const agents = { claude, opentoad } satisfies Record<string, Agent>;
+64 -46
View File
@@ -19,17 +19,18 @@ import { modelAliases, resolveCliModel } from "../models.ts";
import { getIdleMs, markActivity } from "../utils/activity.ts";
import { log } from "../utils/cli.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { detectProviderError } from "../utils/providerErrors.ts";
import { addSkill } from "../utils/skills.ts";
import { spawn } from "../utils/subprocess.ts";
import { ThinkingTimer } from "../utils/timer.ts";
import type { TodoTracker } from "../utils/todoTracking.ts";
import { getDevDependencyVersion } from "../utils/version.ts";
import { type AgentResult, type AgentRunContext, type AgentUsage, agent } from "./shared.ts";
// pinned CLI version
const OPENCODE_CLI_VERSION = "1.1.56";
async function installOpencodeCli(): Promise<string> {
return await installFromNpmTarball({
packageName: "opencode-ai",
version: OPENCODE_CLI_VERSION,
version: getDevDependencyVersion("opencode-ai"),
executablePath: "bin/opencode",
installDependencies: true,
});
@@ -53,7 +54,8 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
edit: "allow",
read: "allow",
webfetch: "allow",
external_directory: "deny",
external_directory: "allow",
skill: "allow",
},
mcp: {
[ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl },
@@ -75,9 +77,9 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
// ── model resolution (see wiki/model-resolution.md) ─────────────────────────────
//
// priority:
// 1. OPENCODE_MODEL env var (explicit override)
// 1. PULLFROG_MODEL env var (explicit override)
// 2. explicit slug from repo config / payload
// 3. auto-select: `opencode models` → recommended aliases first, then secondary
// 3. auto-select: `opencode models` → preferred aliases first, then secondary
// 4. undefined → let OpenCode decide
function getOpenCodeModels(cliPath: string): string[] {
@@ -107,9 +109,9 @@ function resolveOpenCodeModel(ctx: {
modelSlug?: string | undefined;
}): string | undefined {
// 1. explicit env var override
const envModel = process.env.OPENCODE_MODEL?.trim();
const envModel = process.env.PULLFROG_MODEL?.trim();
if (envModel) {
log.info(`» model: ${envModel} (override via OPENCODE_MODEL)`);
log.info(`» model: ${envModel} (override via PULLFROG_MODEL)`);
return envModel;
}
@@ -117,7 +119,11 @@ function resolveOpenCodeModel(ctx: {
if (ctx.modelSlug) {
const resolved = resolveCliModel(ctx.modelSlug);
if (resolved) {
log.info(`» model: ${resolved} (from repo config)`);
if (resolved !== ctx.modelSlug) {
log.info(`» model: ${ctx.modelSlug} (resolved to ${resolved})`);
} else {
log.info(`» model: ${resolved}`);
}
return resolved;
}
log.warning(`» unknown model slug "${ctx.modelSlug}" — falling through to auto-select`);
@@ -125,17 +131,17 @@ function resolveOpenCodeModel(ctx: {
// 3. auto-select: ask OpenCode what's available, pick our best curated match.
// `opencode models` returns `provider/model-id` specifiers matching our resolve values exactly.
// two-pass: recommended (top-tier per provider) first, then secondary models.
// two-pass: preferred (top-tier per provider) first, then secondary models.
const availableModels = getOpenCodeModels(ctx.cliPath);
const availableSet = new Set(availableModels);
if (availableSet.size > 0) {
log.debug(`» opencode models (${availableSet.size}): ${availableModels.join(", ")}`);
const match =
modelAliases.find((a) => a.recommended && availableSet.has(a.resolve)) ??
modelAliases.find((a) => a.preferred && availableSet.has(a.resolve)) ??
modelAliases.find((a) => availableSet.has(a.resolve));
if (match) {
log.info(
`» model: ${match.resolve} (auto-selected${match.recommended ? " — recommended" : ""} curated match)`
`» model: ${match.resolve} (auto-selected${match.preferred ? " — preferred" : ""} curated match)`
);
log.warning(`» model auto-selected. ${AUTO_SELECT_WARNING}`);
return match.resolve;
@@ -149,27 +155,6 @@ function resolveOpenCodeModel(ctx: {
return undefined;
}
// ── provider error detection ───────────────────────────────────────────────────
const PROVIDER_ERROR_PATTERNS = [
{ pattern: "429", label: "rate limited (429)" },
{ pattern: "RESOURCE_EXHAUSTED", label: "quota exhausted" },
{ pattern: "quota", label: "quota error" },
{ pattern: "status: 500", label: "provider 500 error" },
{ pattern: "INTERNAL", label: "provider internal error" },
{ pattern: "status: 503", label: "provider unavailable (503)" },
{ pattern: "UNAVAILABLE", label: "provider unavailable" },
{ pattern: "rate limit", label: "rate limited" },
{ pattern: "limit: 0", label: "zero quota" },
];
function detectProviderError(text: string): string | null {
for (const entry of PROVIDER_ERROR_PATTERNS) {
if (text.includes(entry.pattern)) return entry.label;
}
return null;
}
// ── NDJSON event types ─────────────────────────────────────────────────────────
interface OpenCodeInitEvent {
@@ -290,6 +275,7 @@ type RunParams = {
args: string[];
cwd: string;
env: Record<string, string | undefined>;
todoTracker?: TodoTracker | undefined;
};
async function runOpenCode(params: RunParams): Promise<AgentResult> {
@@ -308,7 +294,7 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
function buildUsage(): AgentUsage | undefined {
return accumulatedTokens.input > 0 || accumulatedTokens.output > 0
? {
agent: "opentoad",
agent: "pullfrog",
inputTokens: accumulatedTokens.input,
outputTokens: accumulatedTokens.output,
}
@@ -390,6 +376,17 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
if (event.part?.state?.status === "completed" && event.part.state.output) {
log.debug(` output: ${event.part.state.output}`);
}
// agent's explicit MCP report_progress takes priority over todo tracking
if (toolName.includes("report_progress") && params.todoTracker) {
log.debug("» report_progress detected, disabling todo tracking");
params.todoTracker.cancel();
}
// parse todowrite events for live progress tracking
if (toolName === "todowrite" && params.todoTracker?.enabled) {
params.todoTracker.update(event.part?.state?.input);
}
},
tool_result: (event: OpenCodeToolResultEvent) => {
const toolId = event.part?.callID || event.tool_id;
@@ -469,7 +466,7 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
args: params.args,
cwd: params.cwd,
env: params.env,
activityTimeout: 0,
activityTimeout: 300_000,
stdio: ["ignore", "pipe", "pipe"],
onStdout: async (chunk) => {
const text = chunk.toString();
@@ -531,6 +528,12 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
},
});
if (result.exitCode === 0) {
await params.todoTracker?.flush();
} else {
params.todoTracker?.cancel();
}
const duration = performance.now() - startTime;
log.info(
`» ${params.label} completed in ${Math.round(duration)}ms with exit code ${result.exitCode}`
@@ -584,6 +587,7 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
return { success: true, output: finalOutput || output, usage };
} catch (error) {
params.todoTracker?.cancel();
const duration = performance.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error);
const isActivityTimeout = errorMessage.includes("activity timeout");
@@ -621,13 +625,27 @@ export const opentoad = agent({
run: async (ctx) => {
const cliPath = await installOpencodeCli();
const model = resolveOpenCodeModel({
cliPath,
modelSlug: ctx.payload.model,
});
const model =
ctx.payload.proxyModel ??
resolveOpenCodeModel({
cliPath,
modelSlug: ctx.payload.model,
});
const tempHome = ctx.tmpdir;
mkdirSync(join(tempHome, ".config", "opencode"), { recursive: true });
const homeEnv = {
HOME: ctx.tmpdir,
XDG_CONFIG_HOME: join(ctx.tmpdir, ".config"),
};
mkdirSync(join(homeEnv.XDG_CONFIG_HOME, "opencode"), { recursive: true });
const agentBrowserVersion = getDevDependencyVersion("agent-browser");
addSkill({
ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`,
skill: "agent-browser",
env: homeEnv,
agent: "opencode",
});
const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
@@ -635,8 +653,7 @@ export const opentoad = agent({
// security is enforced via OPENCODE_CONFIG_CONTENT (bash: deny) and MCP tool filtering.
const env: Record<string, string | undefined> = {
...process.env,
HOME: tempHome,
XDG_CONFIG_HOME: join(tempHome, ".config"),
...homeEnv,
OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
GOOGLE_GENERATIVE_AI_API_KEY:
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
@@ -644,15 +661,16 @@ export const opentoad = agent({
const repoDir = process.cwd();
log.debug(`» starting OpenToad (OpenCode): ${cliPath} ${args.join(" ")}`);
log.debug(`» starting Pullfrog (OpenCode): ${cliPath} ${args.join(" ")}`);
log.debug(`» working directory: ${repoDir}`);
return runOpenCode({
label: "OpenToad",
label: "Pullfrog",
cliPath,
args,
cwd: repoDir,
env,
todoTracker: ctx.todoTracker,
});
},
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { log } from "../utils/cli.ts";
import type { ResolvedInstructions } from "../utils/instructions.ts";
import type { ResolvedPayload } from "../utils/payload.ts";
import type { TodoTracker } from "../utils/todoTracking.ts";
/**
* token/cost usage data from a single agent run
@@ -33,6 +34,7 @@ export interface AgentRunContext {
mcpServerUrl: string;
tmpdir: string;
instructions: ResolvedInstructions;
todoTracker?: TodoTracker | undefined;
}
export interface Agent {
@@ -45,7 +47,6 @@ export const agent = (input: Agent): Agent => {
return {
...input,
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
log.info(`» agent: ${input.name}`);
if (ctx.payload.model) log.info(`» model: ${ctx.payload.model}`);
if (ctx.payload.timeout) log.info(`» timeout: ${ctx.payload.timeout}`);
log.info(`» push: ${ctx.payload.push}`);
+1
View File
@@ -0,0 +1 @@
// action-level constants shared across the action runtime
+23941 -22580
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -12,6 +12,7 @@ export type { ModelAlias, ModelProvider, ProviderConfig } from "./models.ts";
export {
getModelEnvVars,
getModelProvider,
getProviderDisplayName,
modelAliases,
parseModel,
providers,
@@ -211,7 +212,7 @@ interface PullRequestSynchronizeEvent extends BasePayloadEvent {
title: string;
body: string | null;
branch: string;
/** SHA before the push -- used to compute incremental diff via `git diff before_sha...HEAD` */
/** SHA before the push -- used to compute incremental range-diff between PR versions */
before_sha: string;
}
+8 -2
View File
@@ -25743,6 +25743,7 @@ async function apiFetch(options) {
}
// utils/retry.ts
import { setTimeout as sleep } from "node:timers/promises";
var defaultShouldRetry = (error2) => {
if (!(error2 instanceof Error)) return false;
return error2.name === "AbortError" || error2.message.includes("fetch failed") || error2.message.includes("ECONNRESET") || error2.message.includes("ETIMEDOUT");
@@ -25763,7 +25764,7 @@ async function retry(fn, options = {}) {
}
const delay = delayMs * attempt;
log.info(`\xBB ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
await sleep(delay);
}
}
throw lastError;
@@ -25898,10 +25899,15 @@ var findInstallationId = async (jwt, repoOwner, repoName) => {
);
};
async function acquireTokenViaGitHubApp(opts) {
if (!process.env.GITHUB_APP_ID || !process.env.GITHUB_PRIVATE_KEY) {
throw new Error(
"cannot acquire token via GitHub App: GITHUB_APP_ID and GITHUB_PRIVATE_KEY must be set"
);
}
const repoContext = parseRepoContext();
const config = {
appId: process.env.GITHUB_APP_ID,
privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n"),
privateKey: process.env.GITHUB_PRIVATE_KEY.replace(/\\n/g, "\n"),
repoOwner: repoContext.owner,
repoName: repoContext.name
};
+1
View File
@@ -18,6 +18,7 @@ export type {
export {
getModelEnvVars,
getModelProvider,
getProviderDisplayName,
ghPullfrogMcpName,
modelAliases,
parseModel,
+142 -5
View File
@@ -1,6 +1,7 @@
// changes to tool permissions should be reflected in wiki/granular-tools.md
import * as core from "@actions/core";
import { deleteProgressComment, reportProgress } from "./mcp/comment.ts";
import {
initToolState,
startMcpHttpServer,
@@ -15,6 +16,7 @@ import {
DEFAULT_ACTIVITY_TIMEOUT_MS,
} from "./utils/activity.ts";
import { resolveAgent } from "./utils/agent.ts";
import { apiFetch } from "./utils/apiFetch.ts";
import { validateAgentApiKey } from "./utils/apiKeys.ts";
import { resolveBody } from "./utils/body.ts";
import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts";
@@ -34,6 +36,7 @@ import { createTempDirectory, setupGit } from "./utils/setup.ts";
import { killTrackedChildren } from "./utils/subprocess.ts";
import { parseTimeString, TIMEOUT_DISABLED } from "./utils/time.ts";
import { Timer } from "./utils/timer.ts";
import { createTodoTracker } from "./utils/todoTracking.ts";
import { getJobToken, resolveTokens } from "./utils/token.ts";
import { resolveRun } from "./utils/workflow.ts";
@@ -63,6 +66,71 @@ function resolveOutputSchema(): Record<string, unknown> | undefined {
return parsed as Record<string, unknown>;
}
import type { ResolvedPayload } from "./utils/payload.ts";
interface OidcCredentials {
requestUrl: string;
requestToken: string;
}
async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<string | null> {
try {
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
const oidcToken = await core.getIDToken("pullfrog-api");
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
const response = await apiFetch({
path: "/api/proxy-token",
method: "POST",
headers: { Authorization: `Bearer ${oidcToken}` },
});
if (!response.ok) {
log.warning(`proxy key mint failed (${response.status})`);
return null;
}
const data = (await response.json()) as { key: string };
return data.key;
} catch (error) {
log.warning(`proxy key mint error: ${error instanceof Error ? error.message : String(error)}`);
return null;
} finally {
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
}
}
async function resolveProxyModel(ctx: {
payload: ResolvedPayload;
oss: boolean;
proxyModel?: string | undefined;
oidcCredentials: OidcCredentials | null;
}): Promise<void> {
// env override = BYOK escape hatch, don't proxy
if (process.env.PULLFROG_MODEL?.trim()) return;
// OSS: server decided the model
if (ctx.oss && ctx.proxyModel) {
if (!ctx.oidcCredentials) {
log.warning("» oss repo but no OIDC credentials available — skipping proxy");
return;
}
const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials });
if (!key) return;
process.env.OPENROUTER_API_KEY = key;
core.setSecret(key);
ctx.payload.proxyModel = ctx.proxyModel;
log.info(`» proxy: oss → ${ctx.proxyModel}`);
return;
}
// managed billing will add its path here later
}
async function writeJobSummary(toolState: ToolState): Promise<void> {
const usageSummary = formatUsageSummary(toolState.usageEntries);
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
@@ -101,25 +169,59 @@ export async function main(): Promise<MainResult> {
const runContext = await resolveRunContextData({ octokit: initialOctokit, token: jobToken });
timer.checkpoint("runContextData");
// inject account-level secrets into process.env (YAML secrets take precedence)
if (runContext.dbSecrets) {
for (const [key, value] of Object.entries(runContext.dbSecrets)) {
if (!process.env[key]) {
process.env[key] = value;
core.setSecret(value);
}
}
const count = Object.keys(runContext.dbSecrets).length;
if (count > 0) log.info(`» ${count} db secret(s) loaded`);
}
// resolve payload to determine shell permission
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
toolState.model = payload.model;
if (payload.event.trigger === "pull_request_synchronize") {
toolState.beforeSha = payload.event.before_sha;
}
// resolve tokens:
// - gitToken: contents permission based on push setting (assumed exfiltratable)
// - mcpToken: full installation token (not exfiltratable via MCP tools)
// resolve tokens first — acquireNewToken needs OIDC env vars for token exchange
await using tokenRef = await resolveTokens({ push: payload.push });
// stash OIDC credentials in memory before wiping from process.env
// the agent's shell commands can't access JS variables, so this is safe
const oidcCredentials: OidcCredentials | null =
process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
? {
requestUrl: process.env.ACTIONS_ID_TOKEN_REQUEST_URL,
requestToken: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
}
: null;
// clear OIDC env vars in restricted mode to prevent agent from minting tokens
if (payload.shell !== "enabled") {
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
}
// proxy decision: mint an OpenRouter key for OSS repos (or later, managed billing)
await resolveProxyModel({
payload,
oss: runContext.oss,
proxyModel: runContext.proxyModel,
oidcCredentials,
});
// create octokit with MCP token for GitHub API calls
const octokit = createOctokit(tokenRef.mcpToken);
const runInfo = await resolveRun({ octokit });
let toolContext: ToolContext | undefined;
let progressCallbackDisabled = false;
let todoTracker: ReturnType<typeof createTodoTracker> | undefined;
try {
if (payload.cwd && process.cwd() !== payload.cwd) {
@@ -147,10 +249,11 @@ export async function main(): Promise<MainResult> {
await using gitAuthServer = await startGitAuthServer(tmpdir);
setGitAuthServer(gitAuthServer);
const agent = resolveAgent();
const agent = resolveAgent({ model: payload.proxyModel ? undefined : payload.model });
validateAgentApiKey({
agent,
model: payload.proxyModel ?? payload.model,
owner: runContext.repo.owner,
name: runContext.repo.name,
});
@@ -187,6 +290,7 @@ export async function main(): Promise<MainResult> {
apiToken: runContext.apiToken,
modes,
postCheckoutScript: runContext.repoSettings.postCheckoutScript,
prepushScript: runContext.repoSettings.prepushScript,
prApproveEnabled: runContext.repoSettings.prApproveEnabled,
modeInstructions: runContext.repoSettings.modeInstructions,
toolState,
@@ -205,6 +309,7 @@ export async function main(): Promise<MainResult> {
repo: runContext.repo,
modes,
outputSchema,
learnings: runContext.repoSettings.learnings,
});
// log instructions as soon as they are fully resolved
const logParts = [
@@ -224,11 +329,22 @@ export async function main(): Promise<MainResult> {
checkIntervalMs: DEFAULT_ACTIVITY_CHECK_INTERVAL_MS,
});
activityTimeout.promise.catch(() => {}); // prevent unhandled rejection if agent wins race
todoTracker = createTodoTracker(async (body) => {
if (progressCallbackDisabled || !toolContext) return;
try {
await reportProgress(toolContext, { body });
} catch (err) {
log.debug(`progress update failed: ${err}`);
}
});
toolState.todoTracker = todoTracker;
const agentPromise = agent.run({
payload,
mcpServerUrl: mcpHttpServer.url,
tmpdir,
instructions,
todoTracker,
});
// timeout enforcement: default is 1 hour, but can be overridden via flags in the prompt:
@@ -270,7 +386,7 @@ export async function main(): Promise<MainResult> {
);
}
// post-agent review cleanup: reportReviewNodeId → follow-up dispatch → delete progress comment.
// post-agent review cleanup: reportReviewNodeId → follow-up re-review dispatch.
// runs after the agent exits so ordering is architecturally guaranteed (no LLM involvement).
// best-effort: cleanup failures must not turn a successful agent run into a failure.
if (toolContext) {
@@ -279,6 +395,25 @@ export async function main(): Promise<MainResult> {
});
}
// clean up stranded progress comments. two cases:
// 1. wasUpdated=false: nothing wrote to the comment ("Leaping into action" orphan)
// 2. tracker published a checklist but the agent never wrote a final summary
// (hasPublished=true, finalSummaryWritten=false).
// in both cases, delete the comment so it doesn't linger with stale content.
// wasUpdated is intentionally NOT set here — cleanup is not a real progress update.
// uses finalSummaryWritten (not todoTracker.enabled) so cleanup survives API failures
// in report_progress where cancel() ran but the write didn't succeed.
const trackerWasLastWriter = todoTracker?.hasPublished && !toolState.finalSummaryWritten;
if (
toolContext &&
toolState.progressCommentId &&
(!toolState.wasUpdated || trackerWasLastWriter)
) {
await deleteProgressComment(toolContext).catch((error) => {
log.debug(`stranded progress comment cleanup failed: ${error}`);
});
}
await writeJobSummary(toolState);
// emit structured output marker for test validation
@@ -294,6 +429,8 @@ export async function main(): Promise<MainResult> {
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "unknown error occurred";
progressCallbackDisabled = true;
todoTracker?.cancel();
killTrackedChildren();
log.error(errorMessage);
+256 -118
View File
@@ -5,6 +5,7 @@ import { type } from "arktype";
import { log } from "../utils/cli.ts";
import { $git } from "../utils/gitAuth.ts";
import { executeLifecycleHook } from "../utils/lifecycle.ts";
import { computeIncrementalDiff } from "../utils/rangeDiff.ts";
import { $ } from "../utils/shell.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
@@ -139,6 +140,7 @@ export type CheckoutPrResult = {
url: string;
headRepo: string;
diffPath: string;
incrementalDiffPath?: string | undefined;
toc: string;
instructions: string;
};
@@ -166,135 +168,236 @@ export async function fetchAndFormatPrDiff(params: FetchPrDiffParams): Promise<F
import type { GitContext } from "../utils/setup.ts";
type CheckoutPrBranchParams = GitContext;
export type PrData = {
number: number;
headSha: string;
headRef: string;
headRepoFullName: string;
baseRef: string;
baseRepoFullName: string;
maintainerCanModify: boolean;
};
interface CheckoutPrBranchResult {
prNumber: number;
isFork: boolean;
forkUrl?: string | undefined; // only set when isFork is true
type EnsureBeforeShaParams = {
sha: string;
octokit: Octokit;
owner: string;
repo: string;
gitToken: string;
isShallow: boolean;
};
type CreateTempBranchParams = {
octokit: Octokit;
owner: string;
repo: string;
ref: string;
sha: string;
};
async function createTempBranch(params: CreateTempBranchParams) {
const response = await params.octokit.rest.git.createRef({
owner: params.owner,
repo: params.repo,
ref: `refs/heads/${params.ref}`,
sha: params.sha,
});
return {
data: response.data,
async [Symbol.asyncDispose]() {
try {
await params.octokit.rest.git.deleteRef({
owner: params.owner,
repo: params.repo,
ref: `heads/${params.ref}`,
});
log.debug(`» deleted temp branch ${params.ref}`);
} catch (e) {
log.debug(
`» failed to delete temp branch ${params.ref}: ${e instanceof Error ? e.message : String(e)}`
);
}
},
};
}
async function ensureBeforeShaReachable(params: EnsureBeforeShaParams): Promise<boolean> {
try {
$("git", ["cat-file", "-t", params.sha], { log: false });
log.debug(`» before_sha ${params.sha.slice(0, 7)} is reachable`);
return true;
} catch {
// not available locally — create a temporary branch to fetch it
}
const tempBranch = `pullfrog/tmp/${params.sha.slice(0, 12)}`;
try {
log.debug(`» before_sha ${params.sha.slice(0, 7)} not reachable, creating temp branch...`);
await using _ref = await createTempBranch({
octokit: params.octokit,
owner: params.owner,
repo: params.repo,
sha: params.sha,
ref: tempBranch,
});
await $git(
"fetch",
["--no-tags", ...(params.isShallow ? ["--depth=1"] : []), "origin", tempBranch],
{ token: params.gitToken }
);
log.debug(`» fetched before_sha via temp branch ${tempBranch}`);
return true;
} catch (e) {
log.debug(`» failed to fetch before_sha: ${e instanceof Error ? e.message : String(e)}`);
return false;
}
}
type CheckoutPrBranchParams = GitContext & {
beforeSha?: string | undefined;
};
/**
* Shared helper to checkout a PR branch and configure fork remotes.
* Assumes origin remote is already configured with authentication.
* Updates toolState.issueNumber and toolState.pushUrl (for fork PRs).
* Updates toolState.issueNumber, toolState.checkoutSha, and toolState.pushUrl (for fork PRs).
*/
export async function checkoutPrBranch(
pullNumber: number,
params: CheckoutPrBranchParams
): Promise<CheckoutPrBranchResult> {
const { octokit, owner, name, gitToken, toolState } = params;
log.info(`» checking out PR #${pullNumber}...`);
export async function checkoutPrBranch(pr: PrData, params: CheckoutPrBranchParams): Promise<void> {
const { octokit, owner, name, gitToken, toolState, beforeSha } = params;
log.info(`» checking out PR #${pr.number}...`);
// fetch PR metadata
const pr = await octokit.rest.pulls.get({
owner,
repo: name,
pull_number: pullNumber,
});
const headRepo = pr.data.head.repo;
if (!headRepo) {
throw new Error(`PR #${pullNumber} source repository was deleted`);
}
const isFork = headRepo.full_name !== pr.data.base.repo.full_name;
const baseBranch = pr.data.base.ref;
const headBranch = pr.data.head.ref;
const isFork = pr.headRepoFullName !== pr.baseRepoFullName;
// always use pr-{number} as local branch name for consistency
// this avoids naming conflicts and makes push config simpler
const localBranch = `pr-${pullNumber}`;
const localBranch = `pr-${pr.number}`;
// compute deepen depth for shallow clones. actions/checkout uses depth=1
// by default, which breaks rebase/log because git can't find the merge base.
// use the GitHub compare API to fetch exactly enough history.
const isShallow =
$("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
let deepenArgs: string[] = [];
if (isShallow) {
let depth = 1000; // fallback
try {
const comparison = await octokit.rest.repos.compareCommits({
owner,
repo: name,
base: baseBranch,
head: `pull/${pullNumber}/head`,
});
depth = comparison.data.behind_by + 10;
log.debug(
`» PR is ${comparison.data.behind_by} commits behind ${baseBranch}, deepening by ${depth}`
);
} catch {
log.debug(`» compare API failed, falling back to --deepen=${depth}`);
}
deepenArgs = [`--deepen=${depth}`];
}
// check if we're already on the correct commit (not just branch name)
// this handles fork PRs where head branch name might match base branch name
const currentSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
const alreadyOnBranch = currentSha === pr.data.head.sha;
toolState.checkoutSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
const alreadyOnBranch = toolState.checkoutSha === pr.headSha;
if (alreadyOnBranch) {
log.debug(`already on PR branch ${localBranch}, skipping checkout`);
} else {
// fetch base branch so origin/<base> exists for diff operations
log.debug(`» fetching base branch (${baseBranch})...`);
await $git("fetch", [...deepenArgs, "--no-tags", "origin", baseBranch], {
token: gitToken,
});
// fetch base branch so origin/<base> exists for diff operations
log.debug(`» fetching base branch (${pr.baseRef})...`);
await $git("fetch", ["--no-tags", "origin", pr.baseRef], { token: gitToken });
// alreadyOnBranch only matches for repeated checkout_pr calls for the same PR in one session
// (without the tip moving), or if an external setup already checked out the PR head.
// normal PR-triggered runs won't match here — actions/checkout lands on a synthesized
// merge commit whose SHA differs from pr.headSha.
//
// so the fetch+checkout block below will almost always execute, and the fetched HEAD
// might differ from pr.headSha. toolState.checkoutSha is set after to capture the actual SHA.
if (!alreadyOnBranch) {
// checkout base branch first to avoid "refusing to fetch into current branch" error
// -B creates or resets the branch to match origin/baseBranch
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`], { log: false });
$("git", ["checkout", "-B", pr.baseRef, `origin/${pr.baseRef}`], { log: false });
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
log.debug(`» fetching PR #${pullNumber} (${localBranch})...`);
await $git("fetch", ["--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`], {
log.debug(`» fetching PR #${pr.number} (${localBranch})...`);
await $git("fetch", ["--no-tags", "origin", `pull/${pr.number}/head:${localBranch}`], {
token: gitToken,
});
// checkout the branch
$("git", ["checkout", localBranch], { log: false });
log.debug(`» checked out PR #${pullNumber}`);
log.debug(`» checked out PR #${pr.number}`);
// make sure toolState.checkoutSha is set to the actual checked-out SHA (which might be different from pr.headSha)
toolState.checkoutSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
}
// ensure base branch is fetched (needed for diff operations)
// fetch if we skipped checkout (already on branch) - otherwise already fetched above
if (alreadyOnBranch) {
log.debug(`» fetching base branch (${baseBranch})...`);
await $git("fetch", [...deepenArgs, "--no-tags", "origin", baseBranch], {
token: gitToken,
});
const beforeShaReachable = beforeSha
? await ensureBeforeShaReachable({
sha: beforeSha,
octokit,
owner,
repo: name,
gitToken,
isShallow,
})
: false;
// compute deepen depth for shallow clones. actions/checkout uses depth=1
// by default, which breaks rebase/log because git can't find the merge base.
// use the GitHub compare API to fetch exactly enough history.
// computed after checkout so compareCommits uses the actual checked-out SHA.
if (isShallow) {
let deepenDepth = 0;
try {
// ahead_by = PR commits past merge base, behind_by = base commits past merge base.
// --deepen extends ALL shallow roots equally (can't deepen a single branch),
// so we need the max across both the PR head and before_sha to ensure all
// three points (base, head, before_sha) reach the merge base in a single deepen call.
const [prComparison, beforeShaComparison] = await Promise.all([
octokit.rest.repos.compareCommits({
owner,
repo: name,
base: pr.baseRef,
head: toolState.checkoutSha,
}),
beforeSha && beforeShaReachable
? octokit.rest.repos.compareCommits({
owner,
repo: name,
base: pr.baseRef,
head: beforeSha,
})
: undefined,
]);
deepenDepth =
Math.max(
prComparison.data.ahead_by,
prComparison.data.behind_by,
beforeShaComparison?.data.ahead_by ?? 0,
beforeShaComparison?.data.behind_by ?? 0
) + 10;
log.debug(
`» PR: ${prComparison.data.ahead_by} ahead / ${prComparison.data.behind_by} behind` +
(beforeShaComparison
? `, before_sha: ${beforeShaComparison.data.ahead_by} ahead / ${beforeShaComparison.data.behind_by} behind`
: "") +
`, deepen by ${deepenDepth}`
);
} catch {
deepenDepth = 1000;
log.debug(`» compare API failed, falling back to --deepen=${deepenDepth}`);
}
// deepen after both branches are fetched so the merge base is reachable from both sides
if (deepenDepth) {
log.debug(`» deepening by ${deepenDepth} to reach merge base...`);
await $git("fetch", [`--deepen=${deepenDepth}`, "--no-tags", "origin"], {
token: gitToken,
});
}
}
// configure push remote for this branch
// NOTE: This always runs regardless of alreadyOnBranch, because setupGit doesn't configure
// fork remotes. This ensures fork PRs can push even when checkout_pr is called after setupGit.
if (isFork) {
const remoteName = `pr-${pullNumber}`;
const remoteName = `pr-${pr.number}`;
// SECURITY: fork URL without token - auth is injected via GIT_ASKPASS in $git()
const forkUrl = `https://github.com/${headRepo.full_name}.git`;
const forkUrl = `https://github.com/${pr.headRepoFullName}.git`;
// add fork as a named remote (suppress logging to avoid "error: remote already exists" spam)
try {
$("git", ["remote", "add", remoteName, forkUrl], { log: false });
log.debug(`» added remote '${remoteName}' for fork ${headRepo.full_name}`);
log.debug(`» added remote '${remoteName}' for fork ${pr.headRepoFullName}`);
} catch {
// remote already exists, update its URL
$("git", ["remote", "set-url", remoteName, forkUrl], { log: false });
log.debug(`» updated remote '${remoteName}' for fork ${headRepo.full_name}`);
log.debug(`» updated remote '${remoteName}' for fork ${pr.headRepoFullName}`);
}
// set branch push config so `git push` knows where to push
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName], { log: false });
// set merge ref so git knows the remote branch name (may differ from local)
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`], { log: false });
log.debug(`» configured branch '${localBranch}' to push to '${remoteName}/${headBranch}'`);
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${pr.headRef}`], { log: false });
log.debug(`» configured branch '${localBranch}' to push to '${remoteName}/${pr.headRef}'`);
// warn if maintainer can't modify (push will likely fail)
if (!pr.data.maintainer_can_modify) {
if (!pr.maintainerCanModify) {
log.warning(
`» fork PR has maintainer_can_modify=false - push operations will fail. ` +
`ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.`
@@ -303,21 +406,21 @@ export async function checkoutPrBranch(
} else {
// for same-repo PRs, push to origin
$("git", ["config", `branch.${localBranch}.pushRemote`, "origin"], { log: false });
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`], { log: false });
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${pr.headRef}`], { log: false });
}
// update toolState
toolState.issueNumber = pullNumber;
toolState.issueNumber = pr.number;
if (isFork) {
toolState.pushUrl = `https://github.com/${headRepo.full_name}.git`;
toolState.pushUrl = `https://github.com/${pr.headRepoFullName}.git`;
}
// store push destination so push_branch can use it directly
// git config is the primary mechanism, but toolState serves as a reliable fallback
// in case git config reads fail in certain environments
toolState.pushDest = {
remoteName: isFork ? `pr-${pullNumber}` : "origin",
remoteBranch: headBranch,
remoteName: isFork ? `pr-${pr.number}` : "origin",
remoteBranch: pr.headRef,
localBranch,
};
@@ -326,12 +429,6 @@ export async function checkoutPrBranch(
event: "post-checkout",
script: params.postCheckoutScript,
});
return {
prNumber: pullNumber,
isFork,
forkUrl: isFork ? `https://github.com/${headRepo.full_name}.git` : undefined,
};
}
export function CheckoutPrTool(ctx: ToolContext) {
@@ -342,7 +439,28 @@ export function CheckoutPrTool(ctx: ToolContext) {
"Returns diffPath pointing to the formatted diff file.",
parameters: CheckoutPr,
execute: execute(async ({ pull_number }) => {
await checkoutPrBranch(pull_number, {
const prResponse = await ctx.octokit.rest.pulls.get({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
});
const headRepo = prResponse.data.head.repo;
if (!headRepo) {
throw new Error(`PR #${pull_number} source repository was deleted`);
}
const pr: PrData = {
number: pull_number,
headSha: prResponse.data.head.sha,
headRef: prResponse.data.head.ref,
headRepoFullName: headRepo.full_name,
baseRef: prResponse.data.base.ref,
baseRepoFullName: prResponse.data.base.repo.full_name,
maintainerCanModify: prResponse.data.maintainer_can_modify,
};
await checkoutPrBranch(pr, {
octokit: ctx.octokit,
owner: ctx.repo.owner,
name: ctx.repo.name,
@@ -350,20 +468,38 @@ export function CheckoutPrTool(ctx: ToolContext) {
toolState: ctx.toolState,
shell: ctx.payload.shell,
postCheckoutScript: ctx.postCheckoutScript,
beforeSha: ctx.toolState.beforeSha,
});
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
});
const headRepo = pr.data.head.repo;
if (!headRepo) {
throw new Error(`PR #${pull_number} source repository was deleted`);
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) {
throw new Error(
"PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
);
}
ctx.toolState.checkoutSha = pr.data.head.sha;
const headShort = ctx.toolState.checkoutSha!.slice(0, 7);
// compute incremental diff if we have a beforeSha to compare against
let incrementalDiffPath: string | undefined;
if (ctx.toolState.beforeSha && ctx.toolState.checkoutSha) {
const beforeShort = ctx.toolState.beforeSha.slice(0, 7);
const incremental = computeIncrementalDiff({
baseBranch: pr.baseRef,
beforeSha: ctx.toolState.beforeSha,
headSha: ctx.toolState.checkoutSha,
});
if (incremental) {
incrementalDiffPath = join(
tempDir,
`pr-${pull_number}-${beforeShort}-${headShort}-incremental.diff`
);
writeFileSync(incrementalDiffPath, incremental);
log.info(
`» incremental diff computed (${incremental.length} bytes) → ${incrementalDiffPath}`
);
}
}
// fetch PR files and format with line numbers
const formatResult = await fetchAndFormatPrDiff({
@@ -374,28 +510,29 @@ export function CheckoutPrTool(ctx: ToolContext) {
});
const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`);
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) {
throw new Error(
"PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
);
}
const diffPath = join(tempDir, `pr-${pull_number}.diff`);
const diffPath = join(tempDir, `pr-${pull_number}-${headShort}.diff`);
writeFileSync(diffPath, formatResult.content);
log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
const incrementalInstructions = incrementalDiffPath
? ` IMPORTANT: incrementalDiffPath contains ONLY the changes since the last reviewed version ` +
`(computed via range-diff). you MUST read incrementalDiffPath FIRST to understand what changed, ` +
`then use diffPath for full PR context. do NOT skip the incremental diff.`
: "";
return {
success: true,
number: pr.data.number,
title: pr.data.title,
base: pr.data.base.ref,
number: prResponse.data.number,
title: prResponse.data.title,
base: pr.baseRef,
localBranch: `pr-${pull_number}`,
remoteBranch: `refs/heads/${pr.data.head.ref}`,
isFork: headRepo.full_name !== pr.data.base.repo.full_name,
maintainerCanModify: pr.data.maintainer_can_modify,
url: pr.data.html_url,
headRepo: headRepo.full_name,
remoteBranch: `refs/heads/${pr.headRef}`,
isFork: pr.headRepoFullName !== pr.baseRepoFullName,
maintainerCanModify: pr.maintainerCanModify,
url: prResponse.data.html_url,
headRepo: pr.headRepoFullName,
diffPath,
incrementalDiffPath,
toc: formatResult.toc,
instructions:
`the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. ` +
@@ -403,7 +540,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
`for example, if the TOC says "src/foo.ts → lines 5-42", read lines 5-42 from diffPath to see that file's changes. ` +
`review files selectively based on relevance rather than reading everything sequentially. ` +
`the local branch is 'localBranch' (pr-{number}), not the remote branch name. ` +
`when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.`,
`when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.` +
incrementalInstructions,
} satisfies CheckoutPrResult;
}),
});
+84 -28
View File
@@ -9,8 +9,15 @@ import { retry } from "../utils/retry.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
/** PATCH workflow-run with plan comment node_id so plan revisions can update that comment in place. */
async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string): Promise<void> {
type CommentNodeIdField = "planCommentNodeId" | "summaryCommentNodeId";
// IMPORTANT: this route authenticates via Pullfrog API JWT (verifyApiToken),
// NOT a GitHub token. use ctx.apiToken here. see wiki/api-auth.md.
export async function updateCommentNodeId(
ctx: ToolContext,
field: CommentNodeIdField,
nodeId: string
): Promise<void> {
if (ctx.runId === undefined || !ctx.apiToken) return;
try {
await retry(
@@ -22,7 +29,7 @@ async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string):
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ planCommentNodeId }),
body: JSON.stringify({ [field]: nodeId }),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
@@ -30,11 +37,11 @@ async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string):
{
maxAttempts: 3,
delayMs: 2000,
label: "updatePlanCommentId",
label: `updateCommentNodeId(${field})`,
}
);
} catch (error) {
log.warning(`updatePlanCommentId exhausted retries: ${error}`);
log.warning(`updateCommentNodeId(${field}) exhausted retries: ${error}`);
}
}
@@ -48,6 +55,7 @@ export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
interface BuildCommentFooterParams {
octokit?: OctokitWithPlugins | undefined;
customParts?: string[] | undefined;
model?: string | undefined;
}
async function buildCommentFooter(params: BuildCommentFooterParams): Promise<string> {
@@ -70,17 +78,14 @@ async function buildCommentFooter(params: BuildCommentFooterParams): Promise<str
}
}
const footerParams = {
return buildPullfrogFooter({
triggeredBy: true,
workflowRun: runId
? { owner: repoContext.owner, repo: repoContext.name, runId, jobId }
: undefined,
};
if (params.customParts && params.customParts.length > 0) {
return buildPullfrogFooter({ ...footerParams, customParts: params.customParts });
}
return buildPullfrogFooter(footerParams);
customParts: params.customParts,
model: params.model,
});
}
function buildImplementPlanLink(
@@ -95,11 +100,17 @@ function buildImplementPlanLink(
export interface AddFooterCtx {
octokit?: OctokitWithPlugins | undefined;
toolState?: { model?: string | undefined } | undefined;
}
export async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
if (/<br\s*\/?>[ \t]*\n(?!\s*\n)/i.test(body)) {
throw new Error(
"body contains <br/> followed by a non-blank line, which breaks GitHub markdown rendering. always add a blank line after <br/> tags."
);
}
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
const footer = await buildCommentFooter({ octokit: ctx.octokit });
const footer = await buildCommentFooter({ octokit: ctx.octokit, model: ctx.toolState?.model });
return `${bodyWithoutFooter}${footer}`;
}
@@ -107,9 +118,9 @@ export const Comment = type({
issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content"),
type: type
.enumerated("Plan", "Comment")
.enumerated("Plan", "Summary", "Comment")
.describe(
"Plan: record this comment as the plan for this run (use report_progress for progress/plan updates on the current run). Comment: regular comment (default)."
"Plan: record as the plan for this run. Summary: record as the PR summary comment (one per PR, updated in place). Comment: regular comment (default)."
)
.optional(),
});
@@ -118,11 +129,35 @@ export function CreateCommentTool(ctx: ToolContext) {
return tool({
name: "create_issue_comment",
description:
"Create a comment on a GitHub issue. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' only when creating a standalone plan comment to record as this run's plan.",
"Create a comment on a GitHub issue or PR. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' for plan comments, type: 'Summary' for PR summary comments.",
parameters: Comment,
execute: execute(async ({ issueNumber, body, type: commentType }) => {
const bodyWithFooter = await addFooter(ctx, body);
// if a summary comment already exists (found by select_mode), update instead of creating
if (commentType === "Summary" && ctx.toolState.existingSummaryCommentId) {
log.info(
`» redirecting create_issue_comment(Summary) to update existing comment ${ctx.toolState.existingSummaryCommentId}`
);
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: ctx.toolState.existingSummaryCommentId,
body: bodyWithFooter,
});
if (result.data.node_id) {
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id);
}
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
};
}
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
@@ -131,7 +166,10 @@ export function CreateCommentTool(ctx: ToolContext) {
});
if (commentType === "Plan" && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
}
if (commentType === "Summary" && result.data.node_id) {
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id);
}
return {
@@ -228,6 +266,7 @@ export async function reportProgress(
const footer = await buildCommentFooter({
octokit: ctx.octokit,
customParts,
model: ctx.toolState.model,
});
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
@@ -241,7 +280,7 @@ export async function reportProgress(
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
}
return {
@@ -265,6 +304,7 @@ export async function reportProgress(
const footer = await buildCommentFooter({
octokit: ctx.octokit,
customParts,
model: ctx.toolState.model,
});
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
@@ -278,7 +318,7 @@ export async function reportProgress(
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
}
return {
@@ -289,7 +329,7 @@ export async function reportProgress(
};
}
// null = progress comment was deliberately deleted (e.g. by create_pull_request_review)
// null = progress comment was deleted by stranded-comment cleanup in main.ts
if (existingCommentId === null) {
return { body, action: "skipped" };
}
@@ -325,6 +365,7 @@ export async function reportProgress(
const footer = await buildCommentFooter({
octokit: ctx.octokit,
customParts,
model: ctx.toolState.model,
});
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
@@ -336,7 +377,7 @@ export async function reportProgress(
});
if (updateResult.data.node_id) {
await updatePlanCommentId(ctx, updateResult.data.node_id);
await updateCommentNodeId(ctx, "planCommentNodeId", updateResult.data.node_id);
}
return {
@@ -359,17 +400,33 @@ export function ReportProgressTool(ctx: ToolContext) {
return tool({
name: "report_progress",
description:
"Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.",
"Share progress on the associated GitHub issue/PR. The first call creates a comment; subsequent calls update it in place. You MUST call this at the end of every run with a brief final summary (1-3 sentences). The completed task list is automatically appended in a collapsible section — do not restate individual steps.",
parameters: ReportProgress,
execute: execute(async (params) => {
const reportParams: { body: string; target_plan_comment?: boolean } = { body: params.body };
let body = params.body;
// for non-plan calls: stop auto-updates, wait for in-flight writes to settle,
// then append completed task list collapsible
if (!params.target_plan_comment && ctx.toolState.todoTracker) {
ctx.toolState.todoTracker.cancel();
await ctx.toolState.todoTracker.settled();
const collapsible = ctx.toolState.todoTracker.renderCollapsible();
if (collapsible) {
body = `${body}\n\n${collapsible}`;
}
}
const reportParams: { body: string; target_plan_comment?: boolean } = { body };
if (params.target_plan_comment !== undefined) {
reportParams.target_plan_comment = params.target_plan_comment;
}
const result = await reportProgress(ctx, reportParams);
if (!params.target_plan_comment) {
ctx.toolState.finalSummaryWritten = true;
}
if (result.action === "skipped") {
// no-op: no comment target, but progress is still tracked for job summary
return {
success: true,
message:
@@ -387,9 +444,9 @@ export function ReportProgressTool(ctx: ToolContext) {
/**
* Delete the progress comment if it exists.
* Used after submitting a PR review since the review body contains all necessary info.
* Sets progressCommentId to null, which prevents future report_progress calls from
* creating a new comment (the agent may call report_progress again after this).
* Used by main.ts for stranded-comment cleanup (orphaned "Leaping into action" or
* checklist left by the todo tracker when the agent didn't call report_progress).
* Sets progressCommentId to null so subsequent report_progress calls are no-ops.
*/
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
const existingCommentId = ctx.toolState.progressCommentId;
@@ -414,7 +471,6 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
// set to null (not undefined) so report_progress skips instead of creating a new comment
ctx.toolState.progressCommentId = null;
ctx.toolState.wasUpdated = true;
return true;
}
+14
View File
@@ -2,6 +2,7 @@ import { regex } from "arkregex";
import { type } from "arktype";
import { log } from "../utils/cli.ts";
import { $git } from "../utils/gitAuth.ts";
import { executeLifecycleHook } from "../utils/lifecycle.ts";
import { $ } from "../utils/shell.ts";
import type { StoredPushDest, ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
@@ -143,6 +144,8 @@ export function PushBranchTool(ctx: ToolContext) {
? ["--force", "-u", pushDest.remoteName, refspec]
: ["-u", pushDest.remoteName, refspec];
await executeLifecycleHook({ event: "prepush", script: ctx.prepushScript });
log.debug(`pushing ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`);
if (force) {
log.warning(`force pushing - this will overwrite remote history`);
@@ -218,6 +221,8 @@ const NOSHELL_BLOCKED_SUBCOMMANDS: Record<string, string> = {
// (avoids false positives like --exclude matching --exec)
const NOSHELL_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
const COLLAPSE_THRESHOLD = 200;
// SECURITY: subcommand must match [a-z][a-z0-9-]* to reject flags passed as the subcommand.
// this blocks injection of global git options like -c, -C, --exec-path, --config-env, etc.
//
@@ -269,6 +274,15 @@ export function GitTool(ctx: ToolContext) {
}
const output = $("git", [subcommand, ...args], { log: false });
const lineCount = output.split("\n").length;
if (lineCount > COLLAPSE_THRESHOLD) {
log.group(`git ${subcommand} output (${lineCount} lines)`, () => {
log.info(output);
});
} else if (output) {
log.info(output);
}
return { success: true, output };
}),
});
+41
View File
@@ -0,0 +1,41 @@
import { type } from "arktype";
import { apiFetch } from "../utils/apiFetch.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
const UpdateLearningsParams = type({
learnings: type.string.describe(
"the FULL merged learnings as a flat bullet list. each line starts with `- `. one discrete, actionable fact per bullet. combine existing bullets from the prompt with your new discoveries. deduplicate — if an existing bullet covers the same fact, update it in place rather than adding a new one. drop bullets that are clearly wrong or no longer relevant to the current codebase. keep the list focused and concise."
),
});
export function UpdateLearningsTool(ctx: ToolContext) {
return tool({
name: "update_learnings",
description:
"persist operational learnings about this repository (setup steps, test commands, key conventions, patterns). ONLY call this when you have high confidence the information is correct and broadly useful for future runs — not for one-off findings or uncertain observations. format: flat bullet list (`- ` per line, one fact per bullet). pass the FULL merged list — combine existing learnings from the prompt with new discoveries. deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.",
parameters: UpdateLearningsParams,
execute: execute(async (params) => {
const response = await apiFetch({
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/learnings`,
method: "PATCH",
headers: {
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({
learnings: params.learnings,
model: ctx.toolState.model,
}),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`failed to update learnings: ${error}`);
}
return { success: true };
}),
});
}
+1
View File
@@ -21,6 +21,7 @@ function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
workflowRun: ctx.runId
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
: undefined,
model: ctx.toolState.model,
});
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
+88 -52
View File
@@ -1,5 +1,6 @@
import type { RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype";
import { ghPullfrogMcpName } from "../external.ts";
import { apiFetch } from "../utils/apiFetch.ts";
import { getApiUrl } from "../utils/apiUrl.ts";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
@@ -8,10 +9,10 @@ import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
function isStatusError(err: unknown): err is { status: number; message?: string } {
return (
typeof err === "object" && err !== null && "status" in err && typeof err.status === "number"
);
function getHttpStatus(err: unknown): number | undefined {
if (typeof err !== "object" || err === null) return undefined;
const status = (err as Record<string, unknown>).status;
return typeof status === "number" ? status : undefined;
}
// one-shot review tool
@@ -35,7 +36,7 @@ export const CreatePullRequestReview = type({
"The file path to comment on (relative to repo root). Must be a file that appears in the PR diff."
),
line: type.number.describe(
"End line of the comment range. For single-line comments, set equal to 'start_line'. Use NEW column from diff format."
"Line number to comment on. For multi-line ranges, this is the end line. Use NEW column from diff format."
),
side: type
.enumerated("LEFT", "RIGHT")
@@ -51,9 +52,11 @@ export const CreatePullRequestReview = type({
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code."
)
.optional(),
start_line: type.number.describe(
"Start line of the comment range. For single-line comments, set equal to 'line'. The range [start_line, line] defines which lines a suggestion replaces."
),
start_line: type.number
.describe(
"Start line for multi-line comment ranges. Omit for single-line comments. The range [start_line, line] defines which lines a suggestion replaces."
)
.optional(),
})
.array()
.describe(
@@ -67,14 +70,15 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
name: "create_pull_request_review",
description:
"Submit a review for an existing pull request. " +
"Each call creates a permanent, visible review on the PR — NEVER submit test or diagnostic reviews. " +
"Reviews with no body AND no comments are silently skipped (nothing to post). " +
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. " +
"Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. " +
"Example replacing lines 42-44 (3 lines) with 5 lines: " +
`{ path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' }` +
" CONSTRAINT: Inline comments can ONLY target files and lines that appear in the PR diff." +
" Commenting on files or lines outside the diff will cause GitHub API errors." +
" Put feedback about code outside the diff in 'body' instead.",
" If GitHub rejects comments due to incorrect line numbers, re-read the diff and retry.",
parameters: CreatePullRequestReview,
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
if (body) body = fixDoubleEscapedString(body);
@@ -82,6 +86,19 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
// set issue context (PRs are issues)
ctx.toolState.issueNumber = pull_number;
// skip empty COMMENT reviews (no body, no inline comments) — nothing to post.
// APPROVE reviews are never skipped: the approval stamp itself is the content.
if (!approved && !body && comments.length === 0) {
log.info(
"review has no body and no inline comments — skipping submission (no issues found)"
);
return {
success: true,
skipped: true,
reason: "no issues found — nothing to post",
};
}
// enforce prApproveEnabled: downgrade APPROVE to COMMENT if disabled
let event: "APPROVE" | "COMMENT" = approved ? "APPROVE" : "COMMENT";
if (event === "APPROVE" && !ctx.prApproveEnabled) {
@@ -95,6 +112,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
pull_number,
event,
};
let latestHeadSha: string | undefined;
if (commit_id) {
params.commit_id = commit_id;
} else {
@@ -103,28 +121,39 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
repo: ctx.repo.name,
pull_number,
});
params.commit_id = pr.data.head.sha;
latestHeadSha = pr.data.head.sha;
// anchor to checkout sha so line numbers match the diff the agent analyzed
params.commit_id = ctx.toolState.checkoutSha ?? latestHeadSha;
if (ctx.toolState.checkoutSha && latestHeadSha !== ctx.toolState.checkoutSha) {
log.info(
`anchoring review to checkout ${ctx.toolState.checkoutSha.slice(0, 7)} ` +
`(HEAD is now ${latestHeadSha.slice(0, 7)})`
);
}
}
if (comments.length > 0) {
type ReviewComment = (typeof params.comments & {})[number];
params.comments = comments.map((comment) => {
let commentBody = fixDoubleEscapedString(comment.body || "");
if (comment.suggestion !== undefined) {
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
}
type ReviewComment = NonNullable<typeof params.comments>[number];
const reviewComments = comments.map((comment) => {
let commentBody = fixDoubleEscapedString(comment.body || "");
if (comment.suggestion !== undefined) {
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
}
const side = comment.side || "RIGHT";
const reviewComment: ReviewComment = {
path: comment.path,
line: comment.line,
body: commentBody,
side,
};
if (comment.start_line != null && comment.start_line !== comment.line) {
reviewComment.start_line = comment.start_line;
reviewComment.start_side = side;
}
return reviewComment;
});
const side = comment.side || "RIGHT";
const reviewComment: ReviewComment = {
path: comment.path,
line: comment.line,
body: commentBody,
side,
start_line: comment.start_line,
start_side: side,
};
return reviewComment;
});
if (reviewComments.length > 0) {
params.comments = reviewComments;
}
// no body → single-step createReview (no footer needed)
@@ -135,20 +164,24 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
? await createAndSubmitWithFooter(ctx, params, {
body,
approved: approved ?? false,
hasComments: comments.length > 0,
hasComments: reviewComments.length > 0,
})
: await ctx.octokit.rest.pulls.createReview(params);
} catch (err: unknown) {
if (isStatusError(err) && err.status === 422 && params.comments?.length) {
const paths = [...new Set(params.comments.map((comment) => comment.path))];
throw new Error(
`${err.message ?? "422 Unprocessable Entity"}. ` +
`The review had ${params.comments.length} inline comment(s) targeting these paths: ${paths.join(", ")}. ` +
`GitHub cannot resolve one or more of these paths in the PR diff (common when the PR has >100 changed files and some are truncated). ` +
`Fix: remove the failing comment(s) and retry. Put their feedback in the review body instead.`
);
}
throw err;
if (getHttpStatus(err) !== 422 || !params.comments?.length) throw err;
const details = params.comments.map((c) => {
const line = c.line ?? 0;
const startLine = c.start_line ?? line;
const range = startLine !== line ? `${startLine}-${line}` : `${line}`;
return `${c.path}:${range} (${c.side ?? "RIGHT"})`;
});
throw new Error(
`GitHub rejected inline comment(s) with "Line could not be resolved". ` +
`This usually means the diff changed since you last read it (new commits pushed). ` +
`Re-read the diff to get current line numbers, or move failing comments to the review body. ` +
`Affected: ${details.join(", ")}`
);
}
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
if (!result.data.id) {
@@ -169,13 +202,16 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
// detect commits pushed since checkout and guide the agent to review them
// inline instead of dispatching a separate workflow run
const headMovedDuringReview =
ctx.toolState.checkoutSha && params.commit_id !== ctx.toolState.checkoutSha;
if (headMovedDuringReview) {
const fromSha = ctx.toolState.checkoutSha!;
const toSha = params.commit_id!;
// advance checkoutSha so the next review submission tracks correctly
if (
ctx.toolState.checkoutSha &&
latestHeadSha &&
latestHeadSha !== ctx.toolState.checkoutSha
) {
const fromSha = ctx.toolState.checkoutSha;
const toSha = latestHeadSha;
// store old checkoutSha as beforeSha so the next checkout_pr computes an incremental diff
ctx.toolState.beforeSha = fromSha;
// advance checkoutSha so the next review submission tracks correctly (just in case, checkout_pr will overwrite it again)
ctx.toolState.checkoutSha = toSha;
log.info(
@@ -193,10 +229,9 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
from: fromSha,
to: toSha,
instructions:
`New commits were pushed while you were reviewing. ` +
`Run \`git pull\` to fetch them, then review the incremental diff ` +
`with \`git diff ${fromSha}...HEAD\`. Submit another review covering ` +
`only the new changes. Do not repeat feedback from your previous review.`,
`new commits were pushed while you were reviewing. ` +
`call \`${ghPullfrogMcpName}/checkout_pr\` again to fetch the latest version — it will compute the incremental diff automatically. ` +
`submit another review covering only the new changes. do not repeat feedback from your previous review.`,
},
};
}
@@ -245,6 +280,7 @@ async function createAndSubmitWithFooter(
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
: undefined,
customParts,
model: ctx.toolState.model,
});
return ctx.octokit.rest.pulls.submitReview({
+118 -17
View File
@@ -2,12 +2,13 @@ import { type } from "arktype";
import { ghPullfrogMcpName } from "../external.ts";
import type { Mode } from "../modes.ts";
import { apiFetch } from "../utils/apiFetch.ts";
import { log } from "../utils/log.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export const SelectModeParams = type({
mode: type.string.describe(
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts')"
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts', 'Summarize')"
),
"issue_number?": type("number").describe(
"optional issue number; when provided with Plan mode, used to look up an existing plan comment for this issue (edit vs create)"
@@ -18,6 +19,10 @@ function resolveMode(modes: Mode[], modeName: string): Mode | null {
return modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase()) ?? null;
}
function learningsStep(n: number): string {
return `${n}. **learnings** (only if high confidence): if you discovered something about repo setup, test commands, conventions, or patterns that you are confident is correct and would reliably help future runs, call \`${ghPullfrogMcpName}/update_learnings\` to persist it. skip this step if you are unsure or the finding is speculative/one-off. format as a flat bullet list (\`- \` per line, one fact per bullet). merge with existing learnings from the prompt — pass the FULL merged list. deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.`;
}
const modeGuidance: Record<string, string> = {
Build: `### Checklist
@@ -39,6 +44,8 @@ const modeGuidance: Record<string, string> = {
- create a PR via \`${ghPullfrogMcpName}/create_pull_request\`
- call \`${ghPullfrogMcpName}/report_progress\` with the final summary including PR link
${learningsStep(5)}
### Notes
For simple, well-defined tasks, skip the plan phase and go straight to build.`,
@@ -85,7 +92,9 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
- push changes via \`${ghPullfrogMcpName}/push_branch\`
- reply to each comment using \`${ghPullfrogMcpName}/reply_to_review_comment\`
- resolve addressed threads via \`${ghPullfrogMcpName}/resolve_review_thread\`
- call \`${ghPullfrogMcpName}/report_progress\` with a brief summary`,
- call \`${ghPullfrogMcpName}/report_progress\` with a brief summary
${learningsStep(6)}`,
Review: `### Checklist
@@ -102,16 +111,15 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
3. Self-critique: review all drafted comments and drop any that are praise, style preferences, speculative/unverified claims, about pre-existing code unrelated to the PR, or not actionable.
4. Submit a **single** review:
- call \`${ghPullfrogMcpName}/create_pull_request_review\` with all comments and a unified summary body
- call \`${ghPullfrogMcpName}/report_progress\` with the summary
- if no actionable issues found, skip the review — just call \`report_progress\` noting the PR was reviewed`,
4. Submit:
- **actionable issues found**: call \`${ghPullfrogMcpName}/create_pull_request_review\` with all comments, a 1-3 sentence summary body, and \`approved: false\`. Then call \`report_progress\` with a 1-sentence summary.
- **no actionable issues found**: do NOT submit a review. Call \`${ghPullfrogMcpName}/report_progress\` with a brief note (e.g., "Reviewed — no issues found.").`,
IncrementalReview: `### Checklist
1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`. Read the diff to identify the major areas of change.
1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata, \`diffPath\` (full diff), and \`incrementalDiffPath\` (changes since last reviewed version, if available).
2. Generate the incremental diff using the \`before_sha\` from EVENT DATA: \`git diff <before_sha>...HEAD\`. This isolates only the new commits. If the command fails (e.g., force-push rewrote history), fall back to reviewing the full PR diff.
2. If \`incrementalDiffPath\` is present, read it to see what changed since the last review. This is a range-diff that isolates the net changes, filtering out base branch noise. If not present, fall back to reviewing the full PR diff.
3. Fetch previous reviews via \`${ghPullfrogMcpName}/list_pull_request_reviews\`. For the most recent Pullfrog review, call \`${ghPullfrogMcpName}/get_review_comments\` with the review ID to retrieve specific prior line-level feedback.
@@ -125,10 +133,10 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
5. Self-critique: drop any comments that are praise, style preferences, speculative, about pre-existing code, or not actionable.
6. Submit a **single** review:
- if actionable issues found: call \`${ghPullfrogMcpName}/create_pull_request_review\` with \`approved: false\`, all comments, and an **empty body** (do NOT include a summary — inline comments speak for themselves and a top-level comment clutters the PR conversation on every re-review)
- if no actionable issues found: submit with \`approved: true\` and an **empty body** (no inline comments, no summary)
- do NOT call \`${ghPullfrogMcpName}/report_progress\` — incremental reviews should be silent`,
6. Submit:
- **actionable issues found**: call \`${ghPullfrogMcpName}/create_pull_request_review\` with \`approved: false\`, all comments, and an **empty body** — inline comments speak for themselves, and a top-level body clutters the PR conversation on every re-review cycle. Then call \`report_progress\` with a 1-sentence summary.
- **no actionable issues, but substantive changes or prior fixes confirmed**: post a brief comment (1-3 sentences) via \`${ghPullfrogMcpName}/create_issue_comment\` confirming the review happened and listing which prior review issues were resolved. Substantive = new functionality, behavior changes, architectural changes, or fixes to previously flagged issues.
- **no actionable issues, non-substantive changes only** (e.g., trivial formatting, import reordering, comment tweaks with no functional impact): do NOT submit a review. Call \`${ghPullfrogMcpName}/report_progress\` with a brief note (e.g., "Re-reviewed — no new issues found.").`,
Plan: `### Checklist
@@ -138,7 +146,9 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
2. Produce a structured, actionable plan with clear milestones.
3. Call \`${ghPullfrogMcpName}/report_progress\` with the plan.`,
3. Call \`${ghPullfrogMcpName}/report_progress\` with the plan.
${learningsStep(4)}`,
PlanEdit: `### Checklist (editing existing plan)
@@ -169,7 +179,9 @@ An existing plan comment was found for this issue. Update that comment with the
5. Finalize:
- push changes via \`${ghPullfrogMcpName}/push_branch\`
- call \`${ghPullfrogMcpName}/report_progress\` with the diagnosis and fix summary`,
- call \`${ghPullfrogMcpName}/report_progress\` with the diagnosis and fix summary
${learningsStep(6)}`,
Task: `### Checklist
@@ -184,7 +196,43 @@ An existing plan comment was found for this issue. Update that comment with the
3. Finalize:
- call \`${ghPullfrogMcpName}/report_progress\` with results
- if the task involved code changes, push via \`${ghPullfrogMcpName}/push_branch\` and create a PR via \`${ghPullfrogMcpName}/create_pull_request\`
- if the task involved labeling, commenting, or other GitHub operations, perform those directly`,
- if the task involved labeling, commenting, or other GitHub operations, perform those directly
${learningsStep(4)}`,
Summarize: `### Checklist
1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`.
2. Delegate a subagent to analyze the diff and produce a structured summary. Include in its prompt:
- the diff file path
- PR metadata (title, file count, commit count, base/head branches)
- format instructions from EVENT INSTRUCTIONS (if any); otherwise use default format: TL;DR, key changes list, per-change sections with plain-language \`##\` titles and before/after framing
- instruct it to use the TOC to selectively read relevant diff sections, not the entire file
- instruct it to return the full summary markdown as its final response
3. After the subagent completes, call \`${ghPullfrogMcpName}/create_issue_comment\` with \`type: "Summary"\` and the summary body.
4. Call \`${ghPullfrogMcpName}/report_progress\` with a brief note (e.g., "Posted PR summary.").
### Effort
Use mini or auto effort.`,
SummaryUpdate: `### Checklist (updating existing summary)
An existing summary comment was found for this PR. Update it rather than creating a new one.
1. Use \`previousSummaryBody\` from this response as the current summary to revise.
2. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`.
3. Delegate a subagent with:
- the diff file path and PR metadata
- the existing summary body (\`previousSummaryBody\`) so it can update rather than rewrite from scratch
- format instructions from EVENT INSTRUCTIONS (if any)
- instruct it to produce an updated summary reflecting the current state of the PR and return it as its final response
4. After the subagent completes, call \`${ghPullfrogMcpName}/edit_issue_comment\` with \`commentId: existingSummaryCommentId\` (from this response) and the updated summary body.
5. Call \`${ghPullfrogMcpName}/report_progress\` with a brief note (e.g., "Updated PR summary.").
### Effort
Use mini or auto effort.`,
};
type OrchestratorGuidance = {
@@ -218,16 +266,22 @@ function buildOrchestratorGuidance(mode: Mode, opts: BuildGuidanceOpts = {}): Or
// matches the API response for /repo/[owner]/[repo]/issue/[issueNumber]/plan-comment
export type PlanCommentResponsePayload = { error: string } | { commentId: number; body: string };
// matches the API response for /repo/[owner]/[repo]/pr/[prNumber]/summary-comment
export type SummaryCommentResponsePayload = { error: string } | { commentId: number; body: string };
// IMPORTANT: these routes authenticate via GitHub installation token (getEnrichedRepo),
// NOT the Pullfrog API JWT (ctx.apiToken). use ctx.githubInstallationToken here.
// see wiki/api-auth.md for the two auth patterns.
async function fetchExistingPlanComment(
ctx: ToolContext,
issueNumber: number
): Promise<Extract<PlanCommentResponsePayload, { commentId: number }> | null> {
if (!ctx.apiToken) return null;
if (!ctx.githubInstallationToken) return null;
try {
const response = await apiFetch({
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
method: "GET",
headers: { authorization: `Bearer ${ctx.apiToken}` },
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
signal: AbortSignal.timeout(10_000),
});
const data = (await response.json()) as PlanCommentResponsePayload;
@@ -237,6 +291,35 @@ async function fetchExistingPlanComment(
}
}
async function fetchExistingSummaryComment(
ctx: ToolContext,
prNumber: number
): Promise<Extract<SummaryCommentResponsePayload, { commentId: number }> | null> {
if (!ctx.githubInstallationToken) {
log.warning("fetchExistingSummaryComment: no token, skipping");
return null;
}
const path = `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`;
try {
const response = await apiFetch({
path,
method: "GET",
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
signal: AbortSignal.timeout(10_000),
});
const data = (await response.json()) as SummaryCommentResponsePayload;
if (response.ok && "commentId" in data) {
return data;
}
const errMsg = "error" in data ? data.error : "(no error body)";
log.warning(`fetchExistingSummaryComment: ${response.status} ${path}${errMsg}`);
return null;
} catch (error) {
log.warning("fetchExistingSummaryComment failed:", error);
return null;
}
}
export function SelectModeTool(ctx: ToolContext) {
return tool({
name: "select_mode",
@@ -287,6 +370,24 @@ export function SelectModeTool(ctx: ToolContext) {
}
}
if (selectedMode.name === "Summarize") {
const prNumber = ctx.payload.event.issue_number;
if (prNumber !== undefined) {
const existing = await fetchExistingSummaryComment(ctx, prNumber);
if (existing !== null) {
ctx.toolState.existingSummaryCommentId = existing.commentId;
return {
...buildOrchestratorGuidance(selectedMode, {
...guidanceOpts,
overrideGuidance: modeGuidance.SummaryUpdate,
}),
existingSummaryCommentId: existing.commentId,
previousSummaryBody: existing.body,
};
}
}
}
return buildOrchestratorGuidance(selectedMode, guidanceOpts);
}),
});
+30 -2
View File
@@ -1,15 +1,18 @@
// this must be imported first
import "./arkConfig.ts";
import { createServer } from "node:net";
import { setTimeout as sleep } from "node:timers/promises";
import { FastMCP, type Tool } from "fastmcp";
import type { AgentUsage } from "../agents/index.ts";
import { ghPullfrogMcpName } from "../external.ts";
import type { Mode } from "../modes.ts";
import type { PrepResult } from "../prep/index.ts";
import { closeBrowserDaemon } from "../utils/browser.ts";
import { log } from "../utils/cli.ts";
import type { OctokitWithPlugins } from "../utils/github.ts";
import type { ResolvedPayload } from "../utils/payload.ts";
import type { RunContextData } from "../utils/runContextData.ts";
import type { TodoTracker } from "../utils/todoTracking.ts";
import { CheckoutPrTool } from "./checkout.ts";
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
import {
@@ -29,6 +32,7 @@ import { GetIssueCommentsTool } from "./issueComments.ts";
import { GetIssueEventsTool } from "./issueEvents.ts";
import { IssueInfoTool } from "./issueInfo.ts";
import { AddLabelsTool } from "./labels.ts";
import { UpdateLearningsTool } from "./learnings.ts";
import { SetOutputTool } from "./output.ts";
import { CreatePullRequestTool, UpdatePullRequestBodyTool } from "./pr.ts";
import { PullRequestInfoTool } from "./prInfo.ts";
@@ -49,6 +53,8 @@ export type BackgroundProcess = {
pidPath: string;
};
export type BrowserDaemon = { binDir: string; error?: never } | { binDir?: never; error: string };
export type StoredPushDest = {
remoteName: string;
remoteBranch: string;
@@ -66,8 +72,12 @@ export interface ToolState {
issueNumber?: number;
// PR HEAD sha at checkout time — used to detect new commits pushed during a review
checkoutSha?: string;
// SHA to diff incrementally against — set from event payload on first checkout,
// then from checkoutSha when review.ts detects new commits mid-review
beforeSha?: string;
selectedMode?: string;
backgroundProcesses: Map<string, BackgroundProcess>;
browserDaemon?: BrowserDaemon | undefined;
review?: {
id: number;
nodeId: string;
@@ -80,13 +90,23 @@ export interface ToolState {
};
// undefined = no comment yet, number = active comment, null = deliberately deleted
progressCommentId: number | null | undefined;
// immutable snapshot: true if a progress comment was pre-created at init time.
// survives deleteProgressComment so handleAgentResult can still detect "expected but never reported".
hadProgressComment: boolean;
lastProgressBody?: string;
wasUpdated?: boolean;
// set after a non-plan report_progress successfully writes the final summary.
// decoupled from todoTracker.enabled so cleanup detection survives API failures.
finalSummaryWritten?: boolean;
// set by select_mode when Plan + issue_number and plan-comment API returns existing plan (for report_progress target_plan_comment)
existingPlanCommentId?: number;
previousPlanBody?: string;
// set by select_mode when Summarize mode and summary-comment API returns existing summary
existingSummaryCommentId?: number;
output?: string;
usageEntries: AgentUsage[];
model?: string | undefined;
todoTracker?: TodoTracker | undefined;
}
interface InitToolStateParams {
@@ -103,6 +123,7 @@ export function initToolState(params: InitToolStateParams): ToolState {
return {
progressCommentId: resolvedId,
hadProgressComment: !!resolvedId,
backgroundProcesses: new Map(),
usageEntries: [],
};
@@ -117,6 +138,7 @@ export interface ToolContext {
apiToken: string;
modes: Mode[];
postCheckoutScript: string | null;
prepushScript: string | null;
prApproveEnabled: boolean;
modeInstructions: Record<string, string>;
toolState: ToolState;
@@ -188,9 +210,13 @@ function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any
GitTool(ctx),
GitFetchTool(ctx),
UploadFileTool(ctx),
SetOutputTool(ctx, outputSchema),
];
const isStandalone = ctx.payload.event.trigger === "unknown";
if (isStandalone || outputSchema) {
tools.push(SetOutputTool(ctx, outputSchema));
}
// MCP shell with filtered env (no secrets leaked to child processes)
if (ctx.payload.shell === "restricted") {
tools.push(ShellTool(ctx));
@@ -210,6 +236,7 @@ function buildOrchestratorTools(ctx: ToolContext, outputSchema?: JsonSchema): To
DeleteBranchTool(ctx),
CreatePullRequestTool(ctx),
UpdatePullRequestBodyTool(ctx),
UpdateLearningsTool(ctx),
];
}
@@ -301,7 +328,7 @@ async function killBackgroundProcesses(toolState: ToolState): Promise<void> {
// already dead
}
}
await new Promise((resolve) => setTimeout(resolve, 200));
await sleep(200);
for (const proc of backgroundProcesses.values()) {
try {
process.kill(-proc.pid, "SIGKILL");
@@ -329,6 +356,7 @@ export async function startMcpHttpServer(
return {
url: startResult.url,
[Symbol.asyncDispose]: async () => {
closeBrowserDaemon(ctx.toolState);
await killBackgroundProcesses(ctx.toolState);
await startResult.server.stop();
},
+24 -2
View File
@@ -4,7 +4,9 @@ import { randomUUID } from "node:crypto";
import { closeSync, openSync, writeFileSync } from "node:fs";
import { userInfo } from "node:os";
import { join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { type } from "arktype";
import { ensureBrowserDaemon } from "../utils/browser.ts";
import { log } from "../utils/log.ts";
import { resolveEnv } from "../utils/secrets.ts";
import type { ToolContext } from "./server.ts";
@@ -115,7 +117,12 @@ function spawnShell(params: SpawnParams): ChildProcess {
// sudo is only needed for unshare; the actual command should run as the normal user
// to avoid ownership mismatches with files created by the Node.js parent process.
const username = userInfo().username;
const escaped = params.command.replace(/'/g, "'\\''");
// su -p resets PATH on many Linux systems (ALWAYS_SET_PATH in /etc/login.defs).
// restore it from the SANDBOX_PATH env var that survives the su transition.
// biome-ignore lint/suspicious/noTemplateCurlyInString: we need to restore the PATH variable
const pathRestore = 'export PATH="${SANDBOX_PATH:-$PATH}"; ';
const escaped = (pathRestore + params.command).replace(/'/g, "'\\''");
envArgs.push(`SANDBOX_PATH=${params.env.PATH ?? ""}`);
return spawn(
"sudo",
[
@@ -195,6 +202,21 @@ Do NOT use this tool for git commands — use the dedicated git tools instead.`,
const cwd = params.working_directory ?? process.cwd();
const env = resolveEnv(ctx.payload.shell === "enabled" ? "inherit" : "restricted");
if (params.command.includes("agent-browser")) {
const daemonError = ensureBrowserDaemon(ctx.toolState);
if (daemonError) {
return {
output: `browser daemon unavailable: ${daemonError}`,
exit_code: 1,
timed_out: false,
};
}
const binDir = ctx.toolState.browserDaemon?.binDir;
if (binDir) {
env.PATH = `${binDir}:${env.PATH ?? ""}`;
}
}
if (params.background) {
const tempDir = getTempDir();
const handle = `bg-${randomUUID().slice(0, 8)}`;
@@ -305,7 +327,7 @@ export function KillBackgroundTool(ctx: ToolContext) {
} catch {
// already dead
}
await new Promise((resolve) => setTimeout(resolve, 200));
await sleep(200);
try {
process.kill(-proc.pid, "SIGKILL");
} catch {
+19 -4
View File
@@ -35,7 +35,10 @@ describe("getModelProvider", () => {
describe("getModelEnvVars", () => {
it("returns correct env vars for anthropic", () => {
expect(getModelEnvVars("anthropic/claude-opus")).toEqual(["ANTHROPIC_API_KEY"]);
expect(getModelEnvVars("anthropic/claude-opus")).toEqual([
"ANTHROPIC_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
]);
});
it("returns correct env vars for google (multiple)", () => {
@@ -47,6 +50,18 @@ describe("getModelEnvVars", () => {
it("returns empty array for unknown provider", () => {
expect(getModelEnvVars("unknown/model")).toEqual([]);
});
it("returns empty env vars for free opencode models", () => {
expect(getModelEnvVars("opencode/big-pickle")).toEqual([]);
expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual([]);
expect(getModelEnvVars("opencode/mimo-v2-pro-free")).toEqual([]);
expect(getModelEnvVars("opencode/minimax-m2.5-free")).toEqual([]);
expect(getModelEnvVars("opencode/nemotron-3-super-free")).toEqual([]);
});
it("still requires OPENCODE_API_KEY for non-free opencode models", () => {
expect(getModelEnvVars("opencode/claude-opus")).toEqual(["OPENCODE_API_KEY"]);
});
});
describe("resolveModelSlug", () => {
@@ -84,10 +99,10 @@ describe("modelAliases registry", () => {
}
});
it("has exactly one recommended model per provider", () => {
it("has exactly one preferred model per provider", () => {
for (const providerKey of Object.keys(providers)) {
const recommended = modelAliases.filter((a) => a.provider === providerKey && a.recommended);
expect(recommended.length, `${providerKey} should have exactly 1 recommended model`).toBe(1);
const preferred = modelAliases.filter((a) => a.provider === providerKey && a.preferred);
expect(preferred.length, `${providerKey} should have exactly 1 preferred model`).toBe(1);
}
});
+190 -39
View File
@@ -16,15 +16,23 @@ export interface ModelAlias {
displayName: string;
/** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
resolve: string;
/** full models.dev specifier for the OpenRouter equivalent (undefined for free models) */
openRouterResolve: string | undefined;
/** top-tier pick for this provider — preferred during auto-select */
recommended: boolean;
preferred: boolean;
/** whether this alias is free and requires no API key */
isFree: boolean;
}
interface ModelDef {
displayName: string;
/** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
resolve: string;
recommended?: boolean;
/** full models.dev specifier for the OpenRouter equivalent, e.g. "openrouter/anthropic/claude-opus-4.6" */
openRouterResolve?: string;
preferred?: boolean;
envVars?: readonly string[];
isFree?: boolean;
}
export interface ProviderConfig {
@@ -42,45 +50,84 @@ function provider(config: ProviderConfig): ProviderConfig {
export const providers = {
anthropic: provider({
displayName: "Anthropic",
envVars: ["ANTHROPIC_API_KEY"],
envVars: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
models: {
"claude-opus": {
displayName: "Claude Opus",
resolve: "anthropic/claude-opus-4-6",
recommended: true,
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
preferred: true,
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "anthropic/claude-sonnet-4-6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "anthropic/claude-haiku-4-5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5",
},
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "anthropic/claude-sonnet-4-6" },
"claude-haiku": { displayName: "Claude Haiku", resolve: "anthropic/claude-haiku-4-5" },
},
}),
openai: provider({
displayName: "OpenAI",
envVars: ["OPENAI_API_KEY"],
models: {
"gpt-codex": { displayName: "GPT Codex", resolve: "openai/gpt-5.3-codex", recommended: true },
"gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "openai/codex-mini-latest" },
o3: { displayName: "O3", resolve: "openai/o3" },
"gpt-codex": {
displayName: "GPT Codex",
resolve: "openai/gpt-5.3-codex",
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
preferred: true,
},
"gpt-codex-mini": {
displayName: "GPT Codex Mini",
resolve: "openai/codex-mini-latest",
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
},
o3: {
displayName: "O3",
resolve: "openai/o3",
},
},
}),
google: provider({
displayName: "Google",
envVars: ["GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"],
envVars: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
models: {
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "google/gemini-3.1-pro-preview",
recommended: true,
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
preferred: true,
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "google/gemini-3-flash-preview",
openRouterResolve: "openrouter/google/gemini-3-flash-preview",
},
"gemini-flash": { displayName: "Gemini Flash", resolve: "google/gemini-3-flash-preview" },
},
}),
xai: provider({
displayName: "xAI",
envVars: ["XAI_API_KEY"],
models: {
grok: { displayName: "Grok", resolve: "xai/grok-4", recommended: true },
"grok-fast": { displayName: "Grok Fast", resolve: "xai/grok-4-fast" },
"grok-code-fast": { displayName: "Grok Code Fast", resolve: "xai/grok-code-fast-1" },
grok: {
displayName: "Grok",
resolve: "xai/grok-4",
openRouterResolve: "openrouter/x-ai/grok-4",
preferred: true,
},
"grok-fast": {
displayName: "Grok Fast",
resolve: "xai/grok-4-fast",
openRouterResolve: "openrouter/x-ai/grok-4-fast",
},
"grok-code-fast": {
displayName: "Grok Code Fast",
resolve: "xai/grok-code-fast-1",
openRouterResolve: "openrouter/x-ai/grok-code-fast-1",
},
},
}),
deepseek: provider({
@@ -90,16 +137,26 @@ export const providers = {
"deepseek-reasoner": {
displayName: "DeepSeek Reasoner",
resolve: "deepseek/deepseek-reasoner",
recommended: true,
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
preferred: true,
},
"deepseek-chat": {
displayName: "DeepSeek Chat",
resolve: "deepseek/deepseek-chat",
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
},
"deepseek-chat": { displayName: "DeepSeek Chat", resolve: "deepseek/deepseek-chat" },
},
}),
moonshotai: provider({
displayName: "Moonshot AI",
envVars: ["MOONSHOT_API_KEY"],
models: {
"kimi-k2": { displayName: "Kimi K2", resolve: "moonshotai/kimi-k2.5", recommended: true },
"kimi-k2": {
displayName: "Kimi K2",
resolve: "moonshotai/kimi-k2.5",
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
preferred: true,
},
},
}),
opencode: provider({
@@ -109,21 +166,74 @@ export const providers = {
"big-pickle": {
displayName: "Big Pickle",
resolve: "opencode/big-pickle",
recommended: true,
preferred: true,
envVars: [],
isFree: true,
},
"claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-6" },
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" },
"claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" },
"gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" },
"gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" },
"gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" },
"kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" },
"gpt-5-nano": { displayName: "GPT-5 Nano", resolve: "opencode/gpt-5-nano" },
"mimo-v2-flash-free": {
displayName: "MiMo V2 Flash",
resolve: "opencode/mimo-v2-flash-free",
"claude-opus": {
displayName: "Claude Opus",
resolve: "opencode/claude-opus-4-6",
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "opencode/claude-sonnet-4-6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "opencode/claude-haiku-4-5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5",
},
"gpt-codex": {
displayName: "GPT Codex",
resolve: "opencode/gpt-5.3-codex",
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
},
"gpt-codex-mini": {
displayName: "GPT Codex Mini",
resolve: "opencode/gpt-5.1-codex-mini",
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
},
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "opencode/gemini-3.1-pro",
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "opencode/gemini-3-flash",
openRouterResolve: "openrouter/google/gemini-3-flash-preview",
},
"kimi-k2": {
displayName: "Kimi K2",
resolve: "opencode/kimi-k2.5",
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
},
"gpt-5-nano": {
displayName: "GPT Nano",
resolve: "opencode/gpt-5-nano",
envVars: [],
isFree: true,
},
"mimo-v2-pro-free": {
displayName: "MiMo V2 Pro",
resolve: "opencode/mimo-v2-pro-free",
envVars: [],
isFree: true,
},
"minimax-m2.5-free": {
displayName: "MiniMax M2.5",
resolve: "opencode/minimax-m2.5-free",
envVars: [],
isFree: true,
},
"nemotron-3-super-free": {
displayName: "Nemotron 3 Super",
resolve: "opencode/nemotron-3-super-free",
envVars: [],
isFree: true,
},
"minimax-m2.5-free": { displayName: "MiniMax M2.5", resolve: "opencode/minimax-m2.5-free" },
},
}),
openrouter: provider({
@@ -133,35 +243,59 @@ export const providers = {
"claude-opus": {
displayName: "Claude Opus",
resolve: "openrouter/anthropic/claude-opus-4.6",
recommended: true,
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
preferred: true,
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "openrouter/anthropic/claude-sonnet-4.6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "openrouter/anthropic/claude-haiku-4.5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5",
},
"gpt-codex": {
displayName: "GPT Codex",
resolve: "openrouter/openai/gpt-5.3-codex",
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
},
"gpt-codex": { displayName: "GPT Codex", resolve: "openrouter/openai/gpt-5.3-codex" },
"gpt-codex-mini": {
displayName: "GPT Codex Mini",
resolve: "openrouter/openai/gpt-5.1-codex-mini",
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
},
"o4-mini": {
displayName: "O4 Mini",
resolve: "openrouter/openai/o4-mini",
openRouterResolve: "openrouter/openai/o4-mini",
},
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "openrouter/google/gemini-3.1-pro-preview",
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "openrouter/google/gemini-3-flash-preview",
openRouterResolve: "openrouter/google/gemini-3-flash-preview",
},
grok: {
displayName: "Grok",
resolve: "openrouter/x-ai/grok-4",
openRouterResolve: "openrouter/x-ai/grok-4",
},
grok: { displayName: "Grok", resolve: "openrouter/x-ai/grok-4" },
"deepseek-chat": {
displayName: "DeepSeek Chat",
resolve: "openrouter/deepseek/deepseek-chat-v3.1",
resolve: "openrouter/deepseek/deepseek-v3.2",
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
},
"kimi-k2": {
displayName: "Kimi K2",
resolve: "openrouter/moonshotai/kimi-k2.5",
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
},
"kimi-k2": { displayName: "Kimi K2", resolve: "openrouter/moonshotai/kimi-k2.5" },
},
}),
} satisfies Record<string, ProviderConfig>;
@@ -182,9 +316,24 @@ export function getModelProvider(slug: string): string {
return parseModel(slug).provider;
}
export function getProviderDisplayName(slug: string): string | undefined {
const parsed = parseModel(slug);
return (providers as Record<string, ProviderConfig>)[parsed.provider]?.displayName;
}
export function getModelEnvVars(slug: string): string[] {
const p = getModelProvider(slug);
return (providers as Record<string, ProviderConfig>)[p]?.envVars.slice() ?? [];
const parsed = parseModel(slug);
const providerConfig = (providers as Record<string, ProviderConfig>)[parsed.provider];
if (!providerConfig) {
return [];
}
const modelConfig = providerConfig.models[parsed.model];
if (modelConfig?.envVars) {
return modelConfig.envVars.slice();
}
return providerConfig.envVars.slice();
}
// ── derived flat list ──────────────────────────────────────────────────────────
@@ -196,7 +345,9 @@ export const modelAliases: ModelAlias[] = Object.entries(providers).flatMap(
provider: providerKey,
displayName: def.displayName,
resolve: def.resolve,
recommended: def.recommended ?? false,
openRouterResolve: def.openRouterResolve,
preferred: def.preferred ?? false,
isFree: def.isFree ?? false,
}))
);
+30 -23
View File
@@ -15,7 +15,7 @@ export const ModeSchema = type({
prompt: "string",
});
const reportProgressInstruction = `Use ${ghPullfrogMcpName}/report_progress to share progress and results. Continue calling it as you make progress — it will update the same comment. Never create additional comments manually.`;
const reportProgressInstruction = `Use ${ghPullfrogMcpName}/report_progress to share your **final** results in 1-3 sentences. The completed task list is automatically preserved in a collapsible section below your summary — do not repeat individual steps in the summary. Focus on the outcome and link to any artifacts (PRs, branches). Never create additional comments manually.`;
const dependencyInstallationStep = `If this task will require running tests, builds, linters, or CLI commands that need installed packages, call \`${ghPullfrogMcpName}/start_dependency_installation\` NOW. This is non-blocking and allows dependencies to install in the background while you continue. Later, call \`${ghPullfrogMcpName}/await_dependency_installation\` before running commands that need them. Skip this step if only reading code or answering questions.`;
@@ -47,14 +47,12 @@ export function computeModes(): Mode[] {
7. **COMMIT** - Commit your changes using \`${ghPullfrogMcpName}/git\` (e.g., \`git add .\` then \`git commit -m "message"\`), then push with \`${ghPullfrogMcpName}/push_branch\`. Do NOT use \`git push\` directly - it requires credentials that only the MCP tool provides.
8. **PROGRESS** - ${reportProgressInstruction}
9. **PR** - Determine whether to create a PR (if not already on a PR branch):
8. **PR** - Determine whether to create a PR (if not already on a PR branch):
- **Default behavior**: Create a PR using ${ghPullfrogMcpName}/create_pull_request with an informative title and body. If you are working in the context of an issue (check EVENT DATA for \`issue_number\` where \`is_pr\` is not true), include "Closes #<issue_number>" in the PR body to auto-close the issue when merged.
- **Draft PR request**: If the user explicitly asks for a draft PR (e.g. "draft PR", "create as draft", "WIP"), create a PR with \`draft: true\`.
- **Branch-only request**: If the user explicitly asks for a branch without a PR (e.g. "don't create a PR", "branch only", "just create a branch"), do NOT create a PR. Simply push the branch and report the branch link.
10. **FINAL REPORT** - Call report_progress one final time ONLY if you haven't already included all the important information (PR links, branch links, summary) in a previous report_progress call. If you already called report_progress with complete information including PR links after creating the PR, you do NOT need to call it again. Only make a final call if you need to add missing information. When making the final call, ensure it includes:
9. **FINAL REPORT** - ${reportProgressInstruction} Ensure the summary includes:
- A summary of what was accomplished
- Links to any artifacts created (PRs, branches, issues)
- If you created a PR, ALWAYS include the PR link. e.g.:
@@ -66,7 +64,6 @@ export function computeModes(): Mode[] {
[\`pullfrog/branch-name\`](https://github.com/pullfrog/scratch/tree/pullfrog/branch-name) • [Create PR ➔](https://github.com/pullfrog/scratch/compare/main...pullfrog/branch-name?quick_pull=1&title=<informative_title>&body=<informative_body>)
\`\`\`
Do NOT overwrite a good comment with links/details with a generic message like "I have completed the task. Please review the PR." If your previous report_progress call already contains all the necessary information and links, skip the final call entirely.
`,
},
{
@@ -120,14 +117,13 @@ Keep the progress comment extremely brief. The summary should be 1-2 sentences m
- **Impact analysis**: Identify what was removed, renamed, or deprecated in the PR. Use grep to search the broader codebase for remaining references to those things in code, tests, docs, comments, and configs. Report stale references in the review body.
- Do NOT stop at "this looks reasonable." Dig until you either find a problem or have concrete evidence there isn't one.
4. **DRAFT LINE-BY-LINE COMMENTS** - Every comment must be actionable: the author should need to change something in response. 2-3 sentences max. Use the NEW line number from the diff (second column: \`| OLD | NEW | TYPE | CODE\`). If no issues found, skip to step 5. NO COMPLIMENTS. NO NITPICKING ABOUT CHANGES UNRELATED TO THE MAIN CHANGE. Non-actionable comments (praise, style preferences, minor optimizatfixons, documentation nits) must not be drafted.
4. **DRAFT LINE-BY-LINE COMMENTS** - Every comment must be actionable: the author should need to change something in response. 2-3 sentences max. Use the NEW line number from the diff (second column: \`| OLD | NEW | TYPE | CODE\`). If no issues found, skip to step 5. NO COMPLIMENTS. NO NITPICKING ABOUT CHANGES UNRELATED TO THE MAIN CHANGE. Non-actionable comments (praise, style preferences, minor optimizations, documentation nits) must not be drafted.
5. **WRITE SUMMARY** - Draft a 1-3 sentence summary for the review body. If issues were found, include urgency level and any concerns about code outside the diff. If no issues were found, write a brief approval summary (e.g., "Changes look good. No issues found.").
6. **SUBMIT** — Always submit a review via ${ghPullfrogMcpName}/create_pull_request_review:
- \`body\`: The summary from step 5
- \`comments\`: The inline comments from step 4
- \`approved\`: Set to \`true\` ONLY if the review contains no actionable feedback — neither inline comments nor actionable content in the body. An approval signals "no changes needed."
6. **SUBMIT** — Determine whether to submit a review:
- **Issues found**: Submit via ${ghPullfrogMcpName}/create_pull_request_review with the summary body from step 5, the inline comments from step 4, and \`approved: false\`. Then call \`report_progress\` with a 1-sentence summary (e.g., "Reviewed — found 3 issues.").
- **No issues found**: Do NOT submit a review. Call \`${ghPullfrogMcpName}/report_progress\` with a brief note (e.g., "Reviewed — no issues found.").
${permalinkTip}
`,
@@ -138,12 +134,9 @@ ${permalinkTip}
"Re-review a PR after new commits are pushed; focus on new changes since the last review",
prompt: `Follow these steps to incrementally re-review the PR after new commits were pushed. Focus on what changed since the last review.
1. **CHECKOUT** - Call ${ghPullfrogMcpName}/checkout_pr with the PR number. This gives you the full PR diff via \`diffPath\`.
1. **CHECKOUT** - Call ${ghPullfrogMcpName}/checkout_pr with the PR number. This returns \`diffPath\` (full PR diff) and \`incrementalDiffPath\` (changes since last reviewed version, if available).
2. **INCREMENTAL DIFF** - EVENT DATA contains \`before_sha\` (the HEAD before this push). Generate the incremental diff:
\`git diff <before_sha>...HEAD\`
This shows the changes introduced by this push. Cross-reference with previous reviews (step 3) to confirm coverage of all unreviewed changes — the full PR diff fills any gaps.
**If the diff command fails** (e.g., force-push rewrote history), fall back to reviewing the full PR diff from step 1.
2. **INCREMENTAL DIFF** - If \`incrementalDiffPath\` is present, read it to see what changed since the last review. This is a range-diff that isolates only the net changes, filtering out base branch noise. If not present, fall back to reviewing the full PR diff.
3. **FETCH PREVIOUS REVIEWS** - Use ${ghPullfrogMcpName}/list_pull_request_reviews to find previous Pullfrog reviews. For the most recent one, call ${ghPullfrogMcpName}/get_review_comments with the review ID to see specific line-level feedback. This lets you understand what feedback was already given.
@@ -159,12 +152,10 @@ ${permalinkTip}
6. **DRAFT LINE-BY-LINE COMMENTS** - Every comment must be actionable. 2-3 sentences max. Use the NEW line number from the full PR diff. NO COMPLIMENTS. NO NITPICKING.
7. **WRITE SUMMARY** - Draft a 1-3 sentence summary for the review body. Focus on what changed since the last review and whether the new changes are sound. If issues were found, include urgency level. If no issues were found, write a brief approval summary.
8. **SUBMIT** — Use ${ghPullfrogMcpName}/create_pull_request_review:
- \`body\`: The summary from step 7
- \`comments\`: The inline comments from step 6
- \`approved\`: Set to \`true\` ONLY if the review contains no actionable feedback — neither inline comments nor actionable content in the body. An approval signals "no changes needed."
7. **SUBMIT** Determine whether to submit a review:
- **Issues found**: Submit via ${ghPullfrogMcpName}/create_pull_request_review with \`approved: false\`, the inline comments from step 6, and an **empty body** — inline comments speak for themselves, and a top-level body clutters the PR conversation on every re-review cycle. Then call \`report_progress\` with a 1-sentence summary (e.g., "Re-reviewed — found 2 issues in the new commits.").
- **No issues, but substantive changes or prior fixes confirmed**: Post a brief comment (1-3 sentences) via ${ghPullfrogMcpName}/create_issue_comment confirming the review happened and listing which prior review issues were resolved. Substantive = new functionality, behavior changes, architectural changes, or fixes to previously flagged issues.
- **No issues, non-substantive changes only** (e.g., trivial formatting, import reordering, comment tweaks with no functional impact): Do NOT submit a review. Call \`report_progress\` with a brief note (e.g., "Re-reviewed — no new issues found.").
${permalinkTip}
`,
@@ -308,9 +299,25 @@ Your job is to fix issues THIS PR introduced, not to fix all CI failures. If in
- **Draft PR request**: If the user explicitly asks for a draft PR (e.g. "draft PR", "create as draft", "WIP"), create a PR with \`draft: true\`.
- **Branch-only request**: If the user explicitly asks for a branch without a PR (e.g. "don't create a PR", "branch only", "just create a branch"), do NOT create a PR. Simply push the branch and report the branch link.
5. **PROGRESS** - ${reportProgressInstruction}`,
},
{
name: "Summarize",
description:
"Summarize a PR with a structured comment that is updated in place on subsequent pushes",
prompt: `Follow these steps.
1. **CHECKOUT** - Call ${ghPullfrogMcpName}/checkout_pr with the PR number to get PR metadata and diffPath.
2. **ANALYZE** - Read the diff file. Use the TOC to selectively read relevant sections — do not read the entire file unless the PR is small.
3. **SUMMARIZE** - Write a structured summary following the format from EVENT INSTRUCTIONS. If no format instructions are provided, produce a concise summary with a TL;DR, key changes list, and per-change sections with human-readable \`##\` titles and before/after framing.
4. **POST** - Call ${ghPullfrogMcpName}/create_issue_comment with type: 'Summary' and the summary body.
5. **PROGRESS** - ${reportProgressInstruction}
Do NOT overwrite a good comment with links/details with a generic message like "I have completed the task." If your previous report_progress call already contains all the necessary information and links, skip the final call entirely.`,
${permalinkTip}`,
},
];
}
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/pullfrog",
"version": "0.0.179",
"version": "0.0.185",
"type": "module",
"files": [
"index.js",
@@ -31,7 +31,6 @@
"@octokit/plugin-throttling": "^11.0.3",
"@octokit/rest": "^22.0.0",
"@octokit/webhooks-types": "^7.6.1",
"@opencode-ai/sdk": "^1.0.143",
"@standard-schema/spec": "1.1.0",
"@toon-format/toon": "^1.0.0",
"ajv": "^8.18.0",
@@ -47,6 +46,8 @@
"turndown": "^7.2.0"
},
"devDependencies": {
"@anthropic-ai/claude-code": "2.1.85",
"agent-browser": "0.21.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"@types/node": "^24.7.2",
"@types/semver": "^7.7.1",
@@ -54,6 +55,7 @@
"arg": "^5.0.2",
"esbuild": "^0.25.9",
"husky": "^9.0.0",
"opencode-ai": "1.1.56",
"typescript": "^5.9.3",
"vitest": "^4.0.17",
"yaml": "^2.8.2"
+304 -15
View File
@@ -26,9 +26,6 @@ importers:
'@octokit/webhooks-types':
specifier: ^7.6.1
version: 7.6.1
'@opencode-ai/sdk':
specifier: ^1.0.143
version: 1.0.143
'@standard-schema/spec':
specifier: 1.1.0
version: 1.1.0
@@ -69,6 +66,9 @@ importers:
specifier: ^7.2.0
version: 7.2.2
devDependencies:
'@anthropic-ai/claude-code':
specifier: 2.1.85
version: 2.1.85
'@modelcontextprotocol/sdk':
specifier: ^1.26.0
version: 1.26.0(zod@4.3.6)
@@ -81,6 +81,9 @@ importers:
'@types/turndown':
specifier: ^5.0.5
version: 5.0.6
agent-browser:
specifier: 0.21.0
version: 0.21.0
arg:
specifier: ^5.0.2
version: 5.0.2
@@ -90,12 +93,15 @@ importers:
husky:
specifier: ^9.0.0
version: 9.1.7
opencode-ai:
specifier: 1.1.56
version: 1.1.56
typescript:
specifier: ^5.9.3
version: 5.9.3
vitest:
specifier: ^4.0.17
version: 4.0.17(@types/node@24.7.2)(yaml@2.8.2)
version: 4.0.17(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2)
yaml:
specifier: ^2.8.2
version: 2.8.2
@@ -114,6 +120,11 @@ packages:
'@actions/io@1.1.3':
resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==}
'@anthropic-ai/claude-code@2.1.85':
resolution: {integrity: sha512-3/q3xTpk9EnBfQ/XsHGkOZniOgQx4sqD95CDKw1mvN1Qw5+9IZTp6ILdds02d7vOM6YuLL0G0zhqsMSAFVse4w==}
engines: {node: '>=18.0.0'}
hasBin: true
'@ark/fs@0.56.0':
resolution: {integrity: sha512-zY/wDDhcvmt6/upQwZM766PAnvIzdEMcgydUGd9pqY9FMGNo9I9uE4RYAfms9AeUUtbZJu2h2Ua0tvFsO5XF4Q==}
@@ -454,6 +465,95 @@ packages:
peerDependencies:
hono: ^4
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
@@ -547,9 +647,6 @@ packages:
'@octokit/webhooks-types@7.6.1':
resolution: {integrity: sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==}
'@opencode-ai/sdk@1.0.143':
resolution: {integrity: sha512-dtmkBfJ7IIAHzL6KCzAlwc9GybfJONVeCsF6ePYySpkuhslDbRkZBJYb5vqGd1H5zdsgjc6JjuvmOf0rPWUL6A==}
'@rollup/rollup-android-arm-eabi@4.55.1':
resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==}
cpu: [arm]
@@ -753,6 +850,10 @@ packages:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
agent-browser@0.21.0:
resolution: {integrity: sha512-isVHEeb2WL5hLhr4o+zNmcYwmBrldxvrH+FIoRoUmDxyrHr3bhIS6L8BlUMHqT77YtkPq0YSmwoBRrwqeouw9Q==}
hasBin: true
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
@@ -1209,6 +1310,10 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
@@ -1316,6 +1421,65 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
opencode-ai@1.1.56:
resolution: {integrity: sha512-OAF0G/1jVXpOrCh++M5gFEZ0bRLiXOnbxSYMFx5TOoD0OhCjHJS1JlARzaLDAx461qBnn+jocI9BBxD0wwFH3Q==}
hasBin: true
opencode-darwin-arm64@1.1.56:
resolution: {integrity: sha512-0HqvLm7tcYZr4VJgEzi3Wicia5M9yCX66O7Cv470Qu4+GGbCC2sTTmzQu6pCehSleYgkZsiSBNRcFcW/6F7v0Q==}
cpu: [arm64]
os: [darwin]
opencode-darwin-x64-baseline@1.1.56:
resolution: {integrity: sha512-Z9QO9cTC9TnlUxTfEtDbvZbRFxc5Je8rs2Ei0cLH46W9gHGhh1fW4oX64tcrlhF5NUDKJzr/qwdoMfmKkeu53A==}
cpu: [x64]
os: [darwin]
opencode-darwin-x64@1.1.56:
resolution: {integrity: sha512-8ZhNd4sFbDviT1OJs42C35Bx2/z6mkcb7uNhPHX3KKm27KOm7cYjFQa1UTF5or3ZpjkjPQpR/cpo7TELvnnmRA==}
cpu: [x64]
os: [darwin]
opencode-linux-arm64-musl@1.1.56:
resolution: {integrity: sha512-f30SmYX4xE2fUsnNl66dDX++8iTMI9PLXz7BRHhgXL4XAdMpUUJbOKIsw3ZUB0KWHwjR8cjkEJQOZxR3HPbrZg==}
cpu: [arm64]
os: [linux]
opencode-linux-arm64@1.1.56:
resolution: {integrity: sha512-wEmIEXiEKghurQYgKY5yFUFOmnT/QIlAbYnVeH73gSqOwlYFUUlowXLDJnF+3OGd6m000qalhCxjExL7qKINWw==}
cpu: [arm64]
os: [linux]
opencode-linux-x64-baseline-musl@1.1.56:
resolution: {integrity: sha512-EC8EGTJCgTZgGIMZdQjOKGXw02+igo0am1Ry6wrPrB0li7XBlYbY6dz8tL3FlknRVMLamtGtb3tXQ6tqbwjX0g==}
cpu: [x64]
os: [linux]
opencode-linux-x64-baseline@1.1.56:
resolution: {integrity: sha512-jKOvhkNLcn0h6zmKX+hDdkODijSVDxjAGTZp8BPbJCCJVxnKll9dYfDMTqxi3YN6yXp6sBvyaJT1mcpf8knkeA==}
cpu: [x64]
os: [linux]
opencode-linux-x64-musl@1.1.56:
resolution: {integrity: sha512-9BFYBPgpY2RrUd7/Ul0VNh+6B9l0FmCNRUV4yYmeFGee9ZDgPem5YGicsKAMQqS/5X0jW3ZWje+KBbBQz4RMwQ==}
cpu: [x64]
os: [linux]
opencode-linux-x64@1.1.56:
resolution: {integrity: sha512-0FP3BzLjn+a9naTtm9hpHibJ+eV1RkI+tItcDQXKwHtvzoBUvytDRP8v6TotEHNtNF0ZLAb3OGurwDIIdtLarg==}
cpu: [x64]
os: [linux]
opencode-windows-x64-baseline@1.1.56:
resolution: {integrity: sha512-zEokdohfoDjWzwULvlSq0Y/tRoVmz2/6GofYO936buPQf09cBcPqpAgLUjZV+pQI0Atyd05YG3BCO4PQxmXzdw==}
cpu: [x64]
os: [win32]
opencode-windows-x64@1.1.56:
resolution: {integrity: sha512-zm/oaWT5uGrW3DumKRHiqv2L2pKwrTvhsT4XUSfIPLQn5EvrYNU6bh3WFO4v71ZBjSIKx5Q7rEvTEQvFiFEZFQ==}
cpu: [x64]
os: [win32]
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
@@ -1760,6 +1924,18 @@ snapshots:
'@actions/io@1.1.3': {}
'@anthropic-ai/claude-code@2.1.85':
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-x64': 0.34.5
'@ark/fs@0.56.0': {}
'@ark/schema@0.56.0':
@@ -1936,6 +2112,68 @@ snapshots:
dependencies:
hono: 4.12.0
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/sourcemap-codec@1.5.5': {}
'@mixmark-io/domino@2.2.0': {}
@@ -2060,8 +2298,6 @@ snapshots:
'@octokit/webhooks-types@7.6.1': {}
'@opencode-ai/sdk@1.0.143': {}
'@rollup/rollup-android-arm-eabi@4.55.1':
optional: true
@@ -2182,13 +2418,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
'@vitest/mocker@4.0.17(vite@7.3.1(@types/node@24.7.2)(yaml@2.8.2))':
'@vitest/mocker@4.0.17(vite@7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.17
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@24.7.2)(yaml@2.8.2)
vite: 7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2)
'@vitest/pretty-format@4.0.17':
dependencies:
@@ -2222,6 +2458,8 @@ snapshots:
mime-types: 3.0.2
negotiator: 1.0.0
agent-browser@0.21.0: {}
ajv-formats@3.0.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@@ -2752,6 +2990,9 @@ snapshots:
isexe@2.0.0: {}
jiti@2.6.1:
optional: true
jose@6.1.3: {}
jose@6.2.0: {}
@@ -2855,6 +3096,53 @@ snapshots:
dependencies:
wrappy: 1.0.2
opencode-ai@1.1.56:
optionalDependencies:
opencode-darwin-arm64: 1.1.56
opencode-darwin-x64: 1.1.56
opencode-darwin-x64-baseline: 1.1.56
opencode-linux-arm64: 1.1.56
opencode-linux-arm64-musl: 1.1.56
opencode-linux-x64: 1.1.56
opencode-linux-x64-baseline: 1.1.56
opencode-linux-x64-baseline-musl: 1.1.56
opencode-linux-x64-musl: 1.1.56
opencode-windows-x64: 1.1.56
opencode-windows-x64-baseline: 1.1.56
opencode-darwin-arm64@1.1.56:
optional: true
opencode-darwin-x64-baseline@1.1.56:
optional: true
opencode-darwin-x64@1.1.56:
optional: true
opencode-linux-arm64-musl@1.1.56:
optional: true
opencode-linux-arm64@1.1.56:
optional: true
opencode-linux-x64-baseline-musl@1.1.56:
optional: true
opencode-linux-x64-baseline@1.1.56:
optional: true
opencode-linux-x64-musl@1.1.56:
optional: true
opencode-linux-x64@1.1.56:
optional: true
opencode-windows-x64-baseline@1.1.56:
optional: true
opencode-windows-x64@1.1.56:
optional: true
package-manager-detector@1.6.0: {}
parse-ms@4.0.0: {}
@@ -3159,7 +3447,7 @@ snapshots:
vary@1.1.2: {}
vite@7.3.1(@types/node@24.7.2)(yaml@2.8.2):
vite@7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2):
dependencies:
esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3)
@@ -3170,12 +3458,13 @@ snapshots:
optionalDependencies:
'@types/node': 24.7.2
fsevents: 2.3.3
jiti: 2.6.1
yaml: 2.8.2
vitest@4.0.17(@types/node@24.7.2)(yaml@2.8.2):
vitest@4.0.17(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.17
'@vitest/mocker': 4.0.17(vite@7.3.1(@types/node@24.7.2)(yaml@2.8.2))
'@vitest/mocker': 4.0.17(vite@7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.17
'@vitest/runner': 4.0.17
'@vitest/snapshot': 4.0.17
@@ -3192,7 +3481,7 @@ snapshots:
tinyexec: 1.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 7.3.1(@types/node@24.7.2)(yaml@2.8.2)
vite: 7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.7.2
+290 -10
View File
@@ -37509,9 +37509,282 @@ function getApiUrl() {
return raw;
}
// models.ts
function provider(config) {
return config;
}
var providers = {
anthropic: provider({
displayName: "Anthropic",
envVars: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
models: {
"claude-opus": {
displayName: "Claude Opus",
resolve: "anthropic/claude-opus-4-6",
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
preferred: true
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "anthropic/claude-sonnet-4-6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6"
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "anthropic/claude-haiku-4-5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5"
}
}
}),
openai: provider({
displayName: "OpenAI",
envVars: ["OPENAI_API_KEY"],
models: {
"gpt-codex": {
displayName: "GPT Codex",
resolve: "openai/gpt-5.3-codex",
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
preferred: true
},
"gpt-codex-mini": {
displayName: "GPT Codex Mini",
resolve: "openai/codex-mini-latest",
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini"
},
o3: {
displayName: "O3",
resolve: "openai/o3"
}
}
}),
google: provider({
displayName: "Google",
envVars: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
models: {
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "google/gemini-3.1-pro-preview",
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
preferred: true
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "google/gemini-3-flash-preview",
openRouterResolve: "openrouter/google/gemini-3-flash-preview"
}
}
}),
xai: provider({
displayName: "xAI",
envVars: ["XAI_API_KEY"],
models: {
grok: {
displayName: "Grok",
resolve: "xai/grok-4",
openRouterResolve: "openrouter/x-ai/grok-4",
preferred: true
},
"grok-fast": {
displayName: "Grok Fast",
resolve: "xai/grok-4-fast",
openRouterResolve: "openrouter/x-ai/grok-4-fast"
},
"grok-code-fast": {
displayName: "Grok Code Fast",
resolve: "xai/grok-code-fast-1",
openRouterResolve: "openrouter/x-ai/grok-code-fast-1"
}
}
}),
deepseek: provider({
displayName: "DeepSeek",
envVars: ["DEEPSEEK_API_KEY"],
models: {
"deepseek-reasoner": {
displayName: "DeepSeek Reasoner",
resolve: "deepseek/deepseek-reasoner",
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
preferred: true
},
"deepseek-chat": {
displayName: "DeepSeek Chat",
resolve: "deepseek/deepseek-chat",
openRouterResolve: "openrouter/deepseek/deepseek-v3.2"
}
}
}),
moonshotai: provider({
displayName: "Moonshot AI",
envVars: ["MOONSHOT_API_KEY"],
models: {
"kimi-k2": {
displayName: "Kimi K2",
resolve: "moonshotai/kimi-k2.5",
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
preferred: true
}
}
}),
opencode: provider({
displayName: "OpenCode",
envVars: ["OPENCODE_API_KEY"],
models: {
"big-pickle": {
displayName: "Big Pickle",
resolve: "opencode/big-pickle",
preferred: true,
envVars: [],
isFree: true
},
"claude-opus": {
displayName: "Claude Opus",
resolve: "opencode/claude-opus-4-6",
openRouterResolve: "openrouter/anthropic/claude-opus-4.6"
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "opencode/claude-sonnet-4-6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6"
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "opencode/claude-haiku-4-5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5"
},
"gpt-codex": {
displayName: "GPT Codex",
resolve: "opencode/gpt-5.3-codex",
openRouterResolve: "openrouter/openai/gpt-5.3-codex"
},
"gpt-codex-mini": {
displayName: "GPT Codex Mini",
resolve: "opencode/gpt-5.1-codex-mini",
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini"
},
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "opencode/gemini-3.1-pro",
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview"
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "opencode/gemini-3-flash",
openRouterResolve: "openrouter/google/gemini-3-flash-preview"
},
"kimi-k2": {
displayName: "Kimi K2",
resolve: "opencode/kimi-k2.5",
openRouterResolve: "openrouter/moonshotai/kimi-k2.5"
},
"gpt-5-nano": {
displayName: "GPT Nano",
resolve: "opencode/gpt-5-nano",
envVars: [],
isFree: true
},
"mimo-v2-pro-free": {
displayName: "MiMo V2 Pro",
resolve: "opencode/mimo-v2-pro-free",
envVars: [],
isFree: true
},
"minimax-m2.5-free": {
displayName: "MiniMax M2.5",
resolve: "opencode/minimax-m2.5-free",
envVars: [],
isFree: true
},
"nemotron-3-super-free": {
displayName: "Nemotron 3 Super",
resolve: "opencode/nemotron-3-super-free",
envVars: [],
isFree: true
}
}
}),
openrouter: provider({
displayName: "OpenRouter",
envVars: ["OPENROUTER_API_KEY"],
models: {
"claude-opus": {
displayName: "Claude Opus",
resolve: "openrouter/anthropic/claude-opus-4.6",
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
preferred: true
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "openrouter/anthropic/claude-sonnet-4.6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6"
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "openrouter/anthropic/claude-haiku-4.5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5"
},
"gpt-codex": {
displayName: "GPT Codex",
resolve: "openrouter/openai/gpt-5.3-codex",
openRouterResolve: "openrouter/openai/gpt-5.3-codex"
},
"gpt-codex-mini": {
displayName: "GPT Codex Mini",
resolve: "openrouter/openai/gpt-5.1-codex-mini",
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini"
},
"o4-mini": {
displayName: "O4 Mini",
resolve: "openrouter/openai/o4-mini",
openRouterResolve: "openrouter/openai/o4-mini"
},
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "openrouter/google/gemini-3.1-pro-preview",
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview"
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "openrouter/google/gemini-3-flash-preview",
openRouterResolve: "openrouter/google/gemini-3-flash-preview"
},
grok: {
displayName: "Grok",
resolve: "openrouter/x-ai/grok-4",
openRouterResolve: "openrouter/x-ai/grok-4"
},
"deepseek-chat": {
displayName: "DeepSeek Chat",
resolve: "openrouter/deepseek/deepseek-v3.2",
openRouterResolve: "openrouter/deepseek/deepseek-v3.2"
},
"kimi-k2": {
displayName: "Kimi K2",
resolve: "openrouter/moonshotai/kimi-k2.5",
openRouterResolve: "openrouter/moonshotai/kimi-k2.5"
}
}
})
};
var modelAliases = Object.entries(providers).flatMap(
([providerKey, config]) => Object.entries(config.models).map(([modelId, def]) => ({
slug: `${providerKey}/${modelId}`,
provider: providerKey,
displayName: def.displayName,
resolve: def.resolve,
openRouterResolve: def.openRouterResolve,
preferred: def.preferred ?? false,
isFree: def.isFree ?? false
}))
);
// utils/buildPullfrogFooter.ts
var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
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>`;
function formatModelLabel(slug) {
const alias = modelAliases.find((a) => a.slug === slug);
if (!alias) return `\`${slug}\``;
return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
}
function buildPullfrogFooter(params) {
const parts = [];
if (params.customParts) {
@@ -37527,12 +37800,12 @@ function buildPullfrogFooter(params) {
if (params.triggeredBy) {
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
}
const allParts = [
...parts,
"[pullfrog.com](https://pullfrog.com)",
"[\u{1D54F}](https://x.com/pullfrogai)"
];
if (params.model) {
parts.push(`Using ${formatModelLabel(params.model)}`);
}
const allParts = [...parts, "[\u{1D54F}](https://x.com/pullfrogai)"];
return `
${PULLFROG_DIVIDER}
<sup>${FROG_LOGO}&nbsp;&nbsp;\uFF5C ${allParts.join(" \uFF5C ")}</sup>`;
}
@@ -41256,8 +41529,8 @@ var LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
var Comment = type({
issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content"),
type: type.enumerated("Plan", "Comment").describe(
"Plan: record this comment as the plan for this run (use report_progress for progress/plan updates on the current run). Comment: regular comment (default)."
type: type.enumerated("Plan", "Summary", "Comment").describe(
"Plan: record as the plan for this run. Summary: record as the PR summary comment (one per PR, updated in place). Comment: regular comment (default)."
).optional()
});
var EditComment = type({
@@ -41284,7 +41557,7 @@ var core3 = __toESM(require_core(), 1);
// package.json
var package_default = {
name: "@pullfrog/pullfrog",
version: "0.0.179",
version: "0.0.185",
type: "module",
files: [
"index.js",
@@ -41315,7 +41588,6 @@ var package_default = {
"@octokit/plugin-throttling": "^11.0.3",
"@octokit/rest": "^22.0.0",
"@octokit/webhooks-types": "^7.6.1",
"@opencode-ai/sdk": "^1.0.143",
"@standard-schema/spec": "1.1.0",
"@toon-format/toon": "^1.0.0",
ajv: "^8.18.0",
@@ -41331,6 +41603,8 @@ var package_default = {
turndown: "^7.2.0"
},
devDependencies: {
"@anthropic-ai/claude-code": "2.1.85",
"agent-browser": "0.21.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"@types/node": "^24.7.2",
"@types/semver": "^7.7.1",
@@ -41338,6 +41612,7 @@ var package_default = {
arg: "^5.0.2",
esbuild: "^0.25.9",
husky: "^9.0.0",
"opencode-ai": "1.1.56",
typescript: "^5.9.3",
vitest: "^4.0.17",
yaml: "^2.8.2"
@@ -41480,10 +41755,15 @@ async function validateStuckProgressComment(params) {
repo: params.repo,
comment_id: commentId
});
if (commentResult.data.body?.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
const body = commentResult.data.body ?? "";
if (body.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
return commentId;
}
if (/^- \[[ x]\] |^- \*\*→\*\* |^- ~~/.test(body)) {
log.info(`[post] comment ${commentId} is stuck on a todo checklist`);
return commentId;
}
log.info(`[post] comment ${commentId} is not stuck (already updated or different content)`);
return null;
} catch (error2) {
+8 -8
View File
@@ -19,20 +19,20 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = `
"releaseDate": "2026-01",
},
"openai": {
"modelId": "gpt-5.4",
"releaseDate": "2026-03-05",
"modelId": "gpt-5.4-nano",
"releaseDate": "2026-03-17",
},
"opencode": {
"modelId": "nemotron-3-super-free",
"releaseDate": "2026-03-11",
"modelId": "qwen3.6-plus-free",
"releaseDate": "2026-03-30",
},
"openrouter": {
"modelId": "openrouter/hunter-alpha",
"releaseDate": "2026-03-11",
"modelId": "qwen/qwen3.6-plus-preview:free",
"releaseDate": "2026-03-30",
},
"xai": {
"modelId": "grok-4.20-experimental-beta-0304-reasoning",
"releaseDate": "2026-03-04",
"modelId": "grok-4.20-multi-agent-0309",
"releaseDate": "2026-03-09",
},
}
`;
+2 -2
View File
@@ -63,7 +63,7 @@ const dynamicAgentsExpression = "$" + "{{ fromJSON(needs.changes.outputs.agents)
const expectedAgentEnvVars = [
"GITHUB_TOKEN",
...new Set(Object.values(providers).flatMap((p) => [...p.envVars])),
"OPENCODE_MODEL",
"PULLFROG_MODEL",
].sort();
const expectedAgnosticEnvVars = ["ANTHROPIC_API_KEY", "GITHUB_TOKEN"].sort();
@@ -114,7 +114,7 @@ describe("ci workflow consistency", () => {
it("changed-agents.sh treats legacy agent files as non-agent changes", () => {
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
input: JSON.stringify(["action/agents/claude.ts", "action/agents/gemini.ts"]),
input: JSON.stringify(["action/agents/codex.ts", "action/agents/gemini.ts"]),
encoding: "utf-8",
});
expect(JSON.parse(output)).toEqual(["opentoad"]);
+86 -2
View File
@@ -46,6 +46,82 @@ describe("models.dev validity", async () => {
}
});
// ── openRouterResolve coverage ─────────────────────────────────────────────────
// models that have no OpenRouter equivalent and require BYOK.
// add a model here ONLY when it genuinely doesn't exist on both models.dev and OpenRouter.
const BYOK_ONLY_MODELS = new Set(["openai/o3"]);
describe("openRouterResolve completeness", () => {
for (const alias of modelAliases) {
if (alias.isFree) continue;
if (BYOK_ONLY_MODELS.has(alias.slug)) continue;
it(`${alias.slug} has openRouterResolve`, () => {
expect(
alias.openRouterResolve,
`non-free model "${alias.slug}" is missing openRouterResolve — add it or add to BYOK_ONLY_MODELS`
).toBeDefined();
});
}
for (const alias of modelAliases) {
if (!alias.isFree) continue;
it(`${alias.slug} (free) does not need openRouterResolve`, () => {
expect(alias.openRouterResolve).toBeUndefined();
});
}
});
describe("openRouterResolve models.dev validity", async () => {
const data = await api;
const seen = new Set<string>();
for (const alias of modelAliases) {
if (!alias.openRouterResolve) continue;
if (seen.has(alias.openRouterResolve)) continue;
seen.add(alias.openRouterResolve);
const parsed = parseResolve(alias.openRouterResolve);
it(`${alias.openRouterResolve} exists on models.dev`, () => {
const providerData = data[parsed.provider];
expect(providerData, `provider "${parsed.provider}" not found on models.dev`).toBeDefined();
const model = providerData.models[parsed.modelId];
expect(
model,
`model "${parsed.modelId}" not found under ${parsed.provider} on models.dev`
).toBeDefined();
});
}
});
type OpenRouterModel = { id: string };
type OpenRouterModelsResponse = { data: OpenRouterModel[] };
const openRouterApi = fetch("https://openrouter.ai/api/v1/models").then(
(r) => r.json() as Promise<OpenRouterModelsResponse>
);
describe("openRouterResolve OpenRouter API validity", async () => {
const orData = await openRouterApi;
const orModelIds = new Set(orData.data.map((m) => m.id));
const seen = new Set<string>();
for (const alias of modelAliases) {
if (!alias.openRouterResolve) continue;
const orModelId = alias.openRouterResolve.slice("openrouter/".length);
if (seen.has(orModelId)) continue;
seen.add(orModelId);
it(`${orModelId} exists on OpenRouter`, () => {
expect(
orModelIds.has(orModelId),
`model "${orModelId}" not found in OpenRouter API (/api/v1/models)`
).toBe(true);
});
}
});
describe("latest model per provider snapshot", async () => {
const data = await api;
const providerKeys = Object.keys(providers) as ModelProvider[];
@@ -58,10 +134,16 @@ describe("latest model per provider snapshot", async () => {
let latest: { modelId: string; releaseDate: string } | undefined;
for (const [modelId, model] of Object.entries(providerData.models)) {
if (model.status === "deprecated") continue;
// skip non-GA models so beta/nightly churn doesn't break the snapshot
if (model.status) continue;
const rd = model.release_date;
if (!rd) continue;
if (!latest || rd > latest.releaseDate) {
// tiebreak by modelId for stable ordering when release dates match
if (
!latest ||
rd > latest.releaseDate ||
(rd === latest.releaseDate && modelId > latest.modelId)
) {
latest = { modelId, releaseDate: rd };
}
}
@@ -70,6 +152,8 @@ describe("latest model per provider snapshot", async () => {
}
}
// when this fails, a provider shipped a new model. check whether we need
// to add or update an alias in models.ts before updating the snapshot.
it("matches snapshot", () => {
expect(latestByProvider).toMatchSnapshot();
});
+1 -1
View File
@@ -307,7 +307,7 @@ async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
// use anthropic sonnet to avoid google quota issues and gemini doom-looping
if (ctx.agent === "opentoad") {
env.OPENCODE_MODEL ??= "anthropic/claude-sonnet-4-5";
env.PULLFROG_MODEL ??= "anthropic/claude-sonnet-4-5";
}
// build file-based env vars for MCP servers that don't inherit parent env
-4
View File
@@ -216,10 +216,6 @@ export async function runAgentStreaming(options: RunStreamingOptions): Promise<A
GITHUB_OUTPUT: githubOutputFile,
};
// clear CI runner's GITHUB_TOKEN so ensureGitHubToken() mints a
// properly scoped token for the target GITHUB_REPOSITORY via OIDC
delete subEnv.GITHUB_TOKEN;
const child = spawn("node", ["play.ts", "--raw", JSON.stringify(fixture)], {
cwd: actionDir,
env: subEnv as Record<string, string>,
+36 -1
View File
@@ -1,6 +1,41 @@
import type { Agent } from "../agents/index.ts";
import { agents } from "../agents/index.ts";
import { getModelProvider } from "../models.ts";
import { log } from "./cli.ts";
export function resolveAgent(): Agent {
function hasEnvVar(name: string): boolean {
const val = process.env[name];
return typeof val === "string" && val.length > 0;
}
function hasClaudeCodeAuth(): boolean {
return hasEnvVar("CLAUDE_CODE_OAUTH_TOKEN") || hasEnvVar("ANTHROPIC_API_KEY");
}
export function resolveAgent(ctx?: { model?: string | undefined }): Agent {
// 1. explicit env var override (escape hatch)
const envAgent = process.env.PULLFROG_AGENT?.trim();
if (envAgent) {
if (envAgent in agents) {
log.info(`» agent: ${envAgent} (override via PULLFROG_AGENT)`);
return agents[envAgent as keyof typeof agents];
}
log.warning(`» unknown PULLFROG_AGENT="${envAgent}" — falling through to auto-select`);
}
// 2. if model is Anthropic and Claude Code credentials are available, use Claude Code
if (ctx?.model) {
try {
const provider = getModelProvider(ctx.model);
if (provider === "anthropic" && hasClaudeCodeAuth()) {
log.info(`» agent: claude (auto-selected for ${ctx.model})`);
return agents.claude;
}
} catch {
// invalid model slug format — fall through
}
}
// 3. default: OpenCode (universal, supports all providers)
return agents.opentoad;
}
+75
View File
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { validateAgentApiKey } from "./apiKeys.ts";
const base = {
agent: { name: "opentoad" },
owner: "test-owner",
name: "test-repo",
};
const savedEnv = { ...process.env };
beforeEach(() => {
// strip all known provider keys so tests start clean
for (const key of Object.keys(process.env)) {
if (key.endsWith("_API_KEY") || key === "CLAUDE_CODE_OAUTH_TOKEN") delete process.env[key];
}
});
afterEach(() => {
process.env = { ...savedEnv };
});
describe("validateAgentApiKey", () => {
describe("free model (no keys required)", () => {
it("passes with zero env keys", () => {
expect(() => validateAgentApiKey({ ...base, model: "opencode/big-pickle" })).not.toThrow();
});
it("passes for other free opencode models", () => {
for (const slug of [
"opencode/gpt-5-nano",
"opencode/mimo-v2-pro-free",
"opencode/minimax-m2.5-free",
"opencode/nemotron-3-super-free",
]) {
expect(() => validateAgentApiKey({ ...base, model: slug })).not.toThrow();
}
});
});
describe("keyed model", () => {
it("passes when the required key is present", () => {
process.env.ANTHROPIC_API_KEY = "sk-test";
expect(() => validateAgentApiKey({ ...base, model: "anthropic/claude-opus" })).not.toThrow();
});
it("throws when the required key is missing", () => {
expect(() => validateAgentApiKey({ ...base, model: "anthropic/claude-opus" })).toThrow(
"no API key found"
);
});
it("passes for opencode keyed model with OPENCODE_API_KEY", () => {
process.env.OPENCODE_API_KEY = "sk-test";
expect(() => validateAgentApiKey({ ...base, model: "opencode/claude-opus" })).not.toThrow();
});
it("throws for opencode keyed model without OPENCODE_API_KEY", () => {
expect(() => validateAgentApiKey({ ...base, model: "opencode/claude-opus" })).toThrow(
"no API key found"
);
});
});
describe("no model (auto-select)", () => {
it("passes when any known provider key is present", () => {
process.env.OPENAI_API_KEY = "sk-test";
expect(() => validateAgentApiKey({ ...base, model: undefined })).not.toThrow();
});
it("throws when no provider keys are present", () => {
expect(() => validateAgentApiKey({ ...base, model: undefined })).toThrow("no API key found");
});
});
});
+28 -5
View File
@@ -1,4 +1,4 @@
import { providers } from "../models.ts";
import { getModelEnvVars, providers } from "../models.ts";
import { getApiUrl } from "./apiUrl.ts";
const knownApiKeys: Set<string> = new Set(Object.values(providers).flatMap((p) => [...p.envVars]));
@@ -20,18 +20,41 @@ to fix this, add the required secret to your GitHub repository:
4. set the value to your API key
5. click "Add secret"
configure your model at ${settingsUrl}`;
configure your model at ${settingsUrl}
for full setup instructions, see https://docs.pullfrog.com/keys`;
}
function hasEnvVar(name: string): boolean {
const value = process.env[name];
return typeof value === "string" && value.length > 0;
}
/** check if the user has a BYOK key for the given model's provider (does not throw) */
export function hasProviderKey(model: string): boolean {
const requiredVars = getModelEnvVars(model);
if (requiredVars.length === 0) return true;
return requiredVars.some((v) => hasEnvVar(v));
}
export function validateAgentApiKey(params: {
agent: { name: string };
model: string | undefined;
owner: string;
name: string;
}): void {
const hasAnyKey = Object.entries(process.env).some(
([key, value]) => value && typeof value === "string" && knownApiKeys.has(key)
);
// if a specific model is configured, only check that model's required env vars
if (params.model) {
const requiredVars = getModelEnvVars(params.model);
// free models have no required env vars — skip validation entirely
if (requiredVars.length === 0) return;
if (requiredVars.some((v) => hasEnvVar(v))) return;
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
}
// no model configured — auto-select requires at least one known provider key
const hasAnyKey = [...knownApiKeys].some((k) => hasEnvVar(k));
if (!hasAnyKey) {
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
}
+133
View File
@@ -0,0 +1,133 @@
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname } from "node:path";
import type { ToolState } from "../mcp/server.ts";
import { log } from "./cli.ts";
import { filterEnv } from "./secrets.ts";
import { getDevDependencyVersion } from "./version.ts";
// agent-browser already discovers chrome via `which` and the playwright cache as fallbacks,
// so this list only needs to cover the GHA ubuntu-latest runner where we know the exact path.
const CHROME_PATHS = ["/usr/bin/google-chrome-stable"];
let systemChromePath: string | undefined;
function findSystemChromePath(): string | undefined {
if (typeof systemChromePath === "string") {
// return cached result but normalize to undefined if empty
return systemChromePath || undefined;
}
for (const p of CHROME_PATHS) {
if (existsSync(p)) {
systemChromePath = p;
log.info(`found system chrome: ${p}`);
return p;
}
}
// set to an empty string to indicate no system chrome found
// and to avoid repeated lookups
systemChromePath = "";
log.info(`no system chrome found (checked: ${CHROME_PATHS.join(", ")})`);
}
function buildEnv(): Record<string, string> {
const env: Record<string, string> = { ...filterEnv() };
const chromePath = findSystemChromePath();
if (chromePath) {
env.AGENT_BROWSER_EXECUTABLE_PATH = chromePath;
}
return env;
}
/**
* ensure the agent-browser daemon is running by issuing a real command.
*
* agent-browser is stateful — it manages a persistent browser process via a
* daemon that communicates over a Unix socket. we start the daemon here,
* outside of ShellTool, because ShellTool's child process lifecycle would
* kill it between invocations and the daemon must survive across calls.
*
* despite ShellTool commands running inside unshare-sandboxed namespaces,
* they can still reach this daemon because the Unix socket is discoverable
* regardless of unshare's PID/mount isolation. starting the daemon in the
* host namespace keeps it alive while sandboxed shells come and go.
*
* agent-browser auto-starts its daemon on the first CLI invocation and
* keeps it alive via the socket for subsequent commands.
* we run `open about:blank` as the seed command to trigger this.
* idempotent — only runs once.
*/
export function ensureBrowserDaemon(toolState: ToolState): string | undefined {
if (toolState.browserDaemon) {
return toolState.browserDaemon.error;
}
const agentBrowserVersion = getDevDependencyVersion("agent-browser");
log.info(`installing agent-browser@${agentBrowserVersion}...`);
const install = spawnSync("npm", ["install", "-g", `agent-browser@${agentBrowserVersion}`], {
stdio: "pipe",
encoding: "utf-8",
});
if (install.status !== 0) {
const error = `agent-browser install failed: ${(install.stderr || install.stdout || "unknown error").trim()}`;
log.error(error);
toolState.browserDaemon = { error };
return error;
}
log.info("agent-browser installed");
let binDir: string;
try {
const binPath = execFileSync("which", ["agent-browser"], { encoding: "utf-8" }).trim();
binDir = dirname(binPath);
log.info(`agent-browser binary: ${binPath}`);
} catch {
const error = "agent-browser binary not found in PATH after install";
log.error(error);
toolState.browserDaemon = { error };
return error;
}
const env = buildEnv();
// `open about:blank` triggers daemon auto-start and returns once the daemon + browser are ready
log.info("starting browser daemon...");
const seed = spawnSync("agent-browser", ["open", "about:blank"], {
env,
stdio: "pipe",
encoding: "utf-8",
timeout: 30_000,
});
if (seed.status !== 0) {
const output = (seed.stderr || seed.stdout || "unknown error").trim();
const error = `agent-browser open about:blank failed (exit=${seed.status}): ${output}`;
log.error(error);
toolState.browserDaemon = { error };
return error;
}
log.debug(`seed command done (exit=0): ${(seed.stdout || "").trim()}`);
toolState.browserDaemon = { binDir };
log.info("browser daemon ready");
}
export function closeBrowserDaemon(toolState: ToolState): void {
if (!toolState.browserDaemon?.binDir) {
delete toolState.browserDaemon;
return;
}
delete toolState.browserDaemon;
try {
log.info("closing browser daemon...");
spawnSync("agent-browser", ["close"], {
env: filterEnv(),
stdio: "pipe",
timeout: 10_000,
});
log.info("browser daemon closed");
} catch {
// best-effort
}
}
+19 -11
View File
@@ -1,3 +1,5 @@
import { modelAliases } from "../models.ts";
export const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
const 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>`;
@@ -18,13 +20,21 @@ export interface BuildPullfrogFooterParams {
/** alternative: just pass a pre-built URL directly (for shortlinks etc.) */
workflowRunUrl?: string | undefined;
/** arbitrary custom parts (e.g., action links) */
customParts?: string[];
customParts?: string[] | undefined;
/** model slug from payload (e.g., "anthropic/claude-opus"). shown in footer as "Using `Model Name`" */
model?: string | undefined;
}
function formatModelLabel(slug: string): string {
const alias = modelAliases.find((a) => a.slug === slug);
if (!alias) return `\`${slug}\``;
return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
}
/**
* build a pullfrog footer with configurable parts
* always includes: frog logo at start, pullfrog.com link and X link at end
* order: action links (customParts) > workflow run > attribution > reference links
* always includes: frog logo at start and X link at end
* order: action links (customParts) > workflow run > model > attribution > reference links
*/
export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
const parts: string[] = [];
@@ -45,15 +55,13 @@ export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
}
const allParts = [
...parts,
"[pullfrog.com](https://pullfrog.com)",
"[𝕏](https://x.com/pullfrogai)",
];
if (params.model) {
parts.push(`Using ${formatModelLabel(params.model)}`);
}
return `
${PULLFROG_DIVIDER}
<sup>${FROG_LOGO}&nbsp;&nbsp; ${allParts.join(" ")}</sup>`;
const allParts = [...parts, "[𝕏](https://x.com/pullfrogai)"];
return `\n\n${PULLFROG_DIVIDER}\n<sup>${FROG_LOGO}&nbsp;&nbsp; ${allParts.join(" ")}</sup>`;
}
/**
+2 -1
View File
@@ -115,9 +115,10 @@ const testEnvAllowList = new Set([
"GITHUB_PRIVATE_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"GEMINI_API_KEY",
"GOOGLE_GENERATIVE_AI_API_KEY",
"OPENCODE_MODEL",
"PULLFROG_MODEL",
"LOG_LEVEL",
"DEBUG",
"NODE_ENV",
+1
View File
@@ -36,6 +36,7 @@ export async function reportErrorToComment(ctx: ReportErrorParams): Promise<void
triggeredBy: true,
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined,
customParts,
model: ctx.toolState.model,
});
await octokit.rest.issues.updateComment({
+28 -11
View File
@@ -277,11 +277,17 @@ const findInstallationId = async (
// for local development only
async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<string> {
if (!process.env.GITHUB_APP_ID || !process.env.GITHUB_PRIVATE_KEY) {
throw new Error(
"cannot acquire token via GitHub App: GITHUB_APP_ID and GITHUB_PRIVATE_KEY must be set"
);
}
const repoContext = parseRepoContext();
const config: GitHubAppConfig = {
appId: process.env.GITHUB_APP_ID!,
privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n")!,
appId: process.env.GITHUB_APP_ID,
privateKey: process.env.GITHUB_PRIVATE_KEY.replace(/\\n/g, "\n"),
repoOwner: repoContext.owner,
repoName: repoContext.name,
};
@@ -292,20 +298,31 @@ async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<str
}
/**
* Ensure a GitHub token is available in the environment.
* ensure a GitHub token is available in the environment.
*
* If neither `GITHUB_TOKEN` nor `GH_TOKEN` is set, attempts to acquire an
* installation token using `GITHUB_APP_ID` / `GITHUB_PRIVATE_KEY`.
* when OIDC is available (CI), always mints a fresh token scoped to
* GITHUB_REPOSITORY — overriding any inherited GITHUB_TOKEN that may
* be scoped to the wrong repo.
*
* **Not intended for production use** — this is a convenience for local
* development and test harnesses where tokens aren't pre-provisioned.
* otherwise falls back to GitHub App credentials for local development.
*
* only called from play.ts (test/dev path) — the live action calls
* main() directly and never calls this.
*/
export async function ensureGitHubToken(): Promise<void> {
// when OIDC is available, always mint a fresh token scoped to
// GITHUB_REPOSITORY. the inherited GITHUB_TOKEN may be scoped to a
// different repo (e.g., runner token for pullfrog/app when tests
// target pullfrog/test-repo).
if (isOIDCAvailable()) {
const token = await acquireNewToken();
process.env.GITHUB_TOKEN = token;
return;
}
if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
if (isOIDCAvailable() || (process.env.GITHUB_APP_ID && process.env.GITHUB_PRIVATE_KEY)) {
const token = await acquireNewToken();
process.env.GITHUB_TOKEN = token;
}
const token = await acquireNewToken();
process.env.GITHUB_TOKEN = token;
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pipeline } from "node:stream/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { log } from "./cli.ts";
export interface InstallFromNpmTarballParams {
@@ -172,7 +173,7 @@ async function fetchWithRetry(
const waitSeconds = parseInt(retryAfter, 10);
if (!Number.isNaN(waitSeconds) && waitSeconds > 0) {
log.info(`» rate limited, waiting ${waitSeconds} seconds before retry...`);
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
await sleep(waitSeconds * 1000);
const retryResponse = await fetch(url, { headers });
if (!retryResponse.ok) {
throw new Error(
+16 -3
View File
@@ -11,6 +11,7 @@ interface InstructionsContext {
repo: RunContextData["repo"];
modes: Mode[];
outputSchema?: Record<string, unknown> | undefined;
learnings: string | null;
}
function buildRuntimeContext(ctx: InstructionsContext): string {
@@ -211,7 +212,13 @@ When posting comments via ${ghPullfrogMcpName}, write as a professional team mem
### Progress reporting
ALWAYS use \`report_progress\` to share your results and progress — never \`create_issue_comment\`. The \`report_progress\` tool updates the pre-created progress comment on the issue/PR. Using \`create_issue_comment\` instead creates duplicate comments and leaves the progress comment stuck in its initial state. The \`create_issue_comment\` tool is only for creating NEW standalone comments unrelated to your task progress.
**Task list**: at the start of every run, create an internal task list based on the steps in your current mode. Update it as you complete each step. The system automatically renders this list to the progress comment — you do not need to call \`report_progress\` for this.
**\`report_progress\`**: you MUST call this exactly once at the end of every run with a brief final summary (1-3 sentences). Never call it for intermediate status updates (e.g., "Checking for changes...", "Starting review...") — the task list handles live progress automatically. Calling \`report_progress\` replaces the task list with your summary and preserves the completed task list in a collapsible section. Keep the summary concise — do not repeat what the task list already shows. Focus on the outcome (what was accomplished, links to artifacts) rather than listing individual steps.
Never use \`create_issue_comment\` for task progress — that creates duplicate comments and leaves the progress comment stuck in its initial state. \`create_issue_comment\` is only for standalone comments unrelated to your current task (e.g., Plan comments, PR Summary comments).
**After a PR review is submitted**, still call \`report_progress\` with your final summary. The progress comment persists as a record of what was done.
### If you get stuck
@@ -328,13 +335,20 @@ interface AssembleFullPromptInput {
runtime: string;
system: string;
contextSections: string;
learnings: string | null;
}
function assembleFullPrompt(ctx: AssembleFullPromptInput): string {
const learningsSection = ctx.learnings
? `************* REPO INTELLIGENCE *************\n\n${ctx.learnings}`
: "";
const rawFull = `************* RUNTIME CONTEXT *************
${ctx.runtime}
${learningsSection}
${ctx.system}
${ctx.contextSections}`;
@@ -359,8 +373,6 @@ ${ctx.modes.map((m) => `- "${m.name}": ${m.description}`).join("\n")}
Follow the mode guidance to complete the task. Use your native file and shell tools for local operations, and the ${ghPullfrogMcpName} MCP tools for GitHub/git operations.
When done, call \`${ghPullfrogMcpName}/set_output\` with the final result. This makes it available as the GitHub Action output.
### No-action cases
If the task clearly requires no work, call \`${ghPullfrogMcpName}/report_progress\` directly to explain why no action is needed.`;
@@ -385,6 +397,7 @@ If the task clearly requires no work, call \`${ghPullfrogMcpName}/report_progres
runtime: inputs.runtime,
system,
contextSections,
learnings: ctx.learnings,
});
return {
+3
View File
@@ -161,6 +161,9 @@ export function resolvePayload(
// permissions: inputs > repoSettings > fallbacks
push: inputs.push ?? repoSettings.push ?? "restricted",
shell: resolvedShell,
// set by proxy logic in main.ts when routing through OpenRouter
proxyModel: undefined as string | undefined,
};
}
+10 -1
View File
@@ -70,11 +70,20 @@ async function validateStuckProgressComment(
comment_id: commentId,
});
if (commentResult.data.body?.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
const body = commentResult.data.body ?? "";
if (body.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
return commentId;
}
// detect stranded todo checklists left by the tracker when the process was killed
// before the agent could call report_progress with a final summary
if (/^- \[[ x]\] |^- \*\*→\*\* |^- ~~/.test(body)) {
log.info(`[post] comment ${commentId} is stuck on a todo checklist`);
return commentId;
}
log.info(`[post] comment ${commentId} is not stuck (already updated or different content)`);
return null;
} catch (error) {
+18
View File
@@ -0,0 +1,18 @@
const PROVIDER_ERROR_PATTERNS = [
{ pattern: "429", label: "rate limited (429)" },
{ pattern: "RESOURCE_EXHAUSTED", label: "quota exhausted" },
{ pattern: "quota", label: "quota error" },
{ pattern: "status: 500", label: "provider 500 error" },
{ pattern: "INTERNAL", label: "provider internal error" },
{ pattern: "status: 503", label: "provider unavailable (503)" },
{ pattern: "UNAVAILABLE", label: "provider unavailable" },
{ pattern: "rate limit", label: "rate limited" },
{ pattern: "limit: 0", label: "zero quota" },
];
export function detectProviderError(text: string): string | null {
for (const entry of PROVIDER_ERROR_PATTERNS) {
if (text.includes(entry.pattern)) return entry.label;
}
return null;
}
+236
View File
@@ -0,0 +1,236 @@
import { describe, expect, it } from "vitest";
import { postProcessRangeDiff } from "./rangeDiff.ts";
describe("postProcessRangeDiff", () => {
it("returns null for identical patches", () => {
const input = "1: abc1234 = 1: def5678 x";
expect(postProcessRangeDiff(input)).toBeNull();
});
it("returns null for empty input", () => {
expect(postProcessRangeDiff("")).toBeNull();
expect(postProcessRangeDiff(" ")).toBeNull();
});
it("returns null when no changes exist between versions", () => {
const input = [
"1: abc1234 ! 1: def5678 x",
" ## src/file.ts ##",
" @@ src/file.ts",
" +const a = 1;",
" +const b = 2;",
].join("\n");
expect(postProcessRangeDiff(input)).toBeNull();
});
it("strips inner diff prefix from content lines", () => {
const input = [
"1: abc1234 ! 1: def5678 x",
" ## src/math.ts ##",
" @@ src/math.ts",
" + const a = 1;",
" -+ const b = 2;",
" ++ const b = 3;",
" + const c = 4;",
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
" ## src/math.ts ##
@@ src/math.ts
const a = 1;
- const b = 2;
+ const b = 3;
const c = 4;"
`);
});
it("handles context lines (inner space prefix)", () => {
const input = [
"1: abc1234 ! 1: def5678 x",
" ## src/file.ts ##",
" @@ src/file.ts",
" const base = true;",
" - const old = true;",
" + const new_ = true;",
" const end = true;",
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
" ## src/file.ts ##
@@ src/file.ts
const base = true;
-const old = true;
+const new_ = true;
const end = true;"
`);
});
it("trims context lines around changes", () => {
const contextBefore = Array.from(
{ length: 9 },
(_, i) => ` +const line${i + 1} = ${i + 1};`
);
const contextAfter = Array.from(
{ length: 6 },
(_, i) => ` +const line${i + 11} = ${i + 11};`
);
const input = [
"1: abc1234 ! 1: def5678 x",
" ## src/large.ts ##",
" @@ src/large.ts",
...contextBefore,
" -+const line10 = 10;",
" ++const line10 = 100;",
...contextAfter,
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
" ## src/large.ts ##
@@ src/large.ts
...
const line7 = 7;
const line8 = 8;
const line9 = 9;
-const line10 = 10;
+const line10 = 100;
const line11 = 11;
const line12 = 12;
const line13 = 13;"
`);
});
it("handles multiple files", () => {
const input = [
"1: abc ! 1: def x",
" ## src/a.ts ##",
" @@ src/a.ts",
" -+old line a",
" ++new line a",
" ## src/b.ts ##",
" @@ src/b.ts",
" +unchanged",
" -+old line b",
" ++new line b",
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
" ## src/a.ts ##
@@ src/a.ts
-old line a
+new line a
## src/b.ts ##
@@ src/b.ts
unchanged
-old line b
+new line b"
`);
});
it("handles new file added in new version", () => {
const input = [
"1: abc ! 1: def x",
" +## src/new.ts (new) ##",
" +@@ src/new.ts (new)",
" ++export const x = 1;",
" ++export const y = 2;",
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
"+## src/new.ts (new) ##
+@@ src/new.ts (new)
+export const x = 1;
+export const y = 2;"
`);
});
it("handles file removed in new version", () => {
const input = [
"1: abc ! 1: def x",
" -## src/old.ts ##",
" -@@ src/old.ts",
" --export const x = 1;",
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
"-## src/old.ts ##
-@@ src/old.ts
-export const x = 1;"
`);
});
it("filters out metadata section via context trimming", () => {
const input = [
"1: abc ! 1: def x",
" @@ Metadata",
" Author: Test <test@test.com>",
" ## Commit message ##",
" x",
" ## src/file.ts ##",
" @@ src/file.ts",
" +const a = 1;",
" -+const b = 2;",
" ++const b = 3;",
" +const c = 4;",
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
" ## src/file.ts ##
@@ src/file.ts
const a = 1;
-const b = 2;
+const b = 3;
const c = 4;"
`);
});
it("uses custom context line count", () => {
const contextBefore = Array.from(
{ length: 5 },
(_, i) => ` +const line${i + 1} = ${i + 1};`
);
const contextAfter = Array.from(
{ length: 5 },
(_, i) => ` +const line${i + 7} = ${i + 7};`
);
const input = [
"1: abc1234 ! 1: def5678 x",
" ## src/file.ts ##",
" @@ src/file.ts",
...contextBefore,
" -+const changed = old;",
" ++const changed = new;",
...contextAfter,
].join("\n");
expect(postProcessRangeDiff(input, 1)).toMatchInlineSnapshot(`
" ## src/file.ts ##
@@ src/file.ts
...
const line5 = 5;
-const changed = old;
+const changed = new;
const line7 = 7;"
`);
});
it("handles two separate change regions in the same file", () => {
const middle = Array.from({ length: 10 }, (_, i) => ` +const mid${i + 1} = ${i + 1};`);
const input = [
"1: abc ! 1: def x",
" ## src/file.ts ##",
" @@ src/file.ts",
" -+const first = old;",
" ++const first = new;",
...middle,
" -+const second = old;",
" ++const second = new;",
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
" ## src/file.ts ##
@@ src/file.ts
-const first = old;
+const first = new;
const mid1 = 1;
const mid2 = 2;
const mid3 = 3;
...
const mid8 = 8;
const mid9 = 9;
const mid10 = 10;
-const second = old;
+const second = new;"
`);
});
});
+182
View File
@@ -0,0 +1,182 @@
import { log } from "./cli.ts";
import { $ } from "./shell.ts";
type ComputeIncrementalDiffParams = {
baseBranch: string;
beforeSha: string;
headSha: string;
};
/**
* computes the incremental diff between two versions of a PR using range-diff
* on virtual squash commits created via `git commit-tree`.
*
* each PR version is squashed into a single synthetic commit (merge-base → tip tree),
* then range-diff compares those two single-commit ranges. this:
* - isolates each version's net effect (base branch noise eliminated via per-version merge bases)
* - avoids commit-matching issues that raw range-diff has with rebases/squashes/reordering
* - creates only loose git objects, no branches or refs (unlike temp-branch squash approaches)
*
* unlike fetchAndFormatPrDiff/formatFilesWithLineNumbers, this output has no line numbers.
* range-diff compares *patches* (diffs-of-diffs), not file trees — its hunk headers are
* `@@ file.ts` breadcrumbs, not positional `@@ -X,Y +A,B @@` markers. reconstructing
* line numbers would require cross-referencing with the v2 diff or content-matching against
* file trees, both of which are fragile (duplicate lines, hunk boundary shifts after rebase).
* a structured interdiff approach (diff two parsed patches, compare only +/- keys via Myers)
* could approximate line numbers but loses semantic precision: range-diff understands patch
* structure natively (rename detection, hunk-aware matching, dual-prefix inner/outer changes),
* while flat key-sequence comparison can misalign duplicate lines and can't distinguish
* "new addition to the PR" from "existing code newly modified by the PR". range-diff is the
* right abstraction here — the incremental diff answers "how did the changeset evolve?",
* not "where in the file is this?", and forcing positional line numbers onto it would be
* semantically misleading.
*
* alternatives considered:
* - plain git diff (two-tree or three-dot): includes base branch changes, no PR isolation
* - patch-text diffing (interdiff / diff-of-diffs): fragile, hunk offset noise on rebase
* - range-diff on raw commit ranges: confused by commit reorganization across force-pushes
*/
export function computeIncrementalDiff(params: ComputeIncrementalDiffParams): string | null {
try {
// $1=beforeSha, $2=baseBranch, $3=headSha
const raw = $(
"sh",
[
"-c",
'old_base=$(git merge-base "$1" "origin/$2") && ' +
'new_base=$(git merge-base "$3" "origin/$2") && ' +
"git range-diff --no-color " +
'"$old_base..$(git commit-tree "$1^{tree}" -p "$old_base" -m x)" ' +
'"$new_base..$(git commit-tree "$3^{tree}" -p "$new_base" -m x)"',
"--",
params.beforeSha,
params.baseBranch,
params.headSha,
],
{ log: false }
);
return postProcessRangeDiff(raw);
} catch (e) {
log.debug(`» range-diff failed: ${e instanceof Error ? e.message : String(e)}`);
return null;
}
}
function isDiffPrefix(ch: string): boolean {
return ch === " " || ch === "+" || ch === "-";
}
/**
* transforms git range-diff output into a clean incremental diff.
*
* range-diff content lines have two prefix characters:
* 1st (outer): range-diff level — space (same in both), + (new only), - (old only)
* 2nd (inner): original diff level — space (context), + (added), - (removed)
*
* stripping the inner prefix produces a standard unified-diff-like output where
* +/- means "changed between PR versions" rather than "changed vs base branch".
*
* uses a streaming approach: a ring buffer of before-context lines is flushed when
* a change is hit, then afterCount lines of after-context are emitted directly.
* nearest preceding ## / @@ headers are force-included when outside the context window.
*/
export function postProcessRangeDiff(raw: string, contextLines = 3): string | null {
if (!raw.trim()) return null;
if (/^\d+:\s+\w+\s+=\s+\d+:/m.test(raw)) return null;
type Line = { prefix: string; from: number; to: number; seq: number };
const beforeBuf: Line[] = [];
let lastFileHdr: Line | null = null;
let lastHunkHdr: Line | null = null;
let fileHdrEmitted = true;
let hunkHdrEmitted = true;
let out = "";
let afterRemaining = 0;
let lastEmittedSeq = -2;
let seq = 0;
let hasChanges = false;
function emit(line: Line) {
if (lastEmittedSeq >= 0 && line.seq > lastEmittedSeq + 1) out += (out ? "\n" : "") + "...";
out += (out ? "\n" : "") + line.prefix + raw.slice(line.from, line.to);
lastEmittedSeq = line.seq;
if (lastFileHdr?.seq === line.seq) fileHdrEmitted = true;
if (lastHunkHdr?.seq === line.seq) hunkHdrEmitted = true;
}
function flushBefore() {
if (lastFileHdr && !fileHdrEmitted) emit(lastFileHdr);
if (lastHunkHdr && !hunkHdrEmitted) emit(lastHunkHdr);
for (const line of beforeBuf) {
if (line.seq > lastEmittedSeq) emit(line);
}
beforeBuf.length = 0;
}
let cursor = 0;
while (cursor < raw.length) {
const eol = raw.indexOf("\n", cursor);
const lineEnd = eol === -1 ? raw.length : eol;
if (raw.charCodeAt(cursor) >= 48 && raw.charCodeAt(cursor) <= 57) {
cursor = lineEnd + 1;
continue;
}
if (lineEnd - cursor >= 5 && raw.startsWith(" ", cursor)) {
const prefix = raw[cursor + 4];
if (isDiffPrefix(prefix)) {
const contentPos = cursor + 5;
const isOuterChange = prefix !== " ";
let line: Line;
let isChange = false;
if (contentPos >= lineEnd) {
line = { prefix, from: lineEnd, to: lineEnd, seq };
} else if (isDiffPrefix(raw[contentPos])) {
isChange = isOuterChange;
line = { prefix, from: contentPos + 1, to: lineEnd, seq };
} else {
line = { prefix, from: contentPos, to: lineEnd, seq };
if (
raw.startsWith("## ", contentPos) &&
!raw.startsWith("## Commit message", contentPos)
) {
lastFileHdr = line;
fileHdrEmitted = false;
lastHunkHdr = null;
hunkHdrEmitted = true;
} else if (
raw.startsWith("@@", contentPos) &&
!raw.startsWith("@@ Metadata", contentPos)
) {
lastHunkHdr = line;
hunkHdrEmitted = false;
}
}
if (isChange) {
hasChanges = true;
flushBefore();
emit(line);
afterRemaining = contextLines;
} else if (afterRemaining > 0) {
emit(line);
afterRemaining--;
} else {
if (beforeBuf.length >= contextLines) beforeBuf.shift();
beforeBuf.push(line);
}
seq++;
}
}
cursor = lineEnd + 1;
}
return hasChanges ? out : null;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { setTimeout as sleep } from "node:timers/promises";
import { log } from "./cli.ts";
export type RetryOptions = {
@@ -38,7 +39,7 @@ export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {})
const delay = delayMs * attempt;
log.info(`» ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
await sleep(delay);
}
}
-3
View File
@@ -1,5 +1,4 @@
import type { WriteablePayload } from "../external.ts";
import { deleteProgressComment } from "../mcp/comment.ts";
import { reportReviewNodeId } from "../mcp/review.ts";
import type { ToolContext } from "../mcp/server.ts";
import { log } from "./cli.ts";
@@ -34,8 +33,6 @@ export async function postReviewCleanup(ctx: ToolContext): Promise<void> {
"follow-up re-review dispatch"
);
}
await bestEffort(() => deleteProgressComment(ctx), "delete progress comment");
}
async function bestEffort(fn: () => Promise<unknown>, label: string): Promise<void> {
+1 -1
View File
@@ -19,7 +19,7 @@ export async function handleAgentResult(ctx: HandleAgentResultParams): Promise<M
};
}
if (!ctx.toolState.wasUpdated && ctx.toolState.progressCommentId && !ctx.silent) {
if (!ctx.toolState.wasUpdated && ctx.toolState.hadProgressComment && !ctx.silent) {
const error = ctx.result.error || "agent completed without reporting progress";
try {
await reportErrorToComment({
+15
View File
@@ -14,15 +14,20 @@ export interface RepoSettings {
modes: Mode[];
setupScript: string | null;
postCheckoutScript: string | null;
prepushScript: string | null;
push: PushPermission;
shell: ShellPermission;
prApproveEnabled: boolean;
modeInstructions: Record<string, string>;
learnings: string | null;
}
export interface RunContext {
settings: RepoSettings;
apiToken: string;
oss: boolean;
proxyModel?: string | undefined;
dbSecrets?: Record<string, string> | undefined;
}
const defaultSettings: RepoSettings = {
@@ -30,15 +35,18 @@ const defaultSettings: RepoSettings = {
modes: [],
setupScript: null,
postCheckoutScript: null,
prepushScript: null,
push: "restricted",
shell: "restricted",
prApproveEnabled: false,
modeInstructions: {},
learnings: null,
};
const defaultRunContext: RunContext = {
settings: defaultSettings,
apiToken: "",
oss: false,
};
/**
@@ -73,6 +81,9 @@ export async function fetchRunContext(params: {
const data = (await response.json()) as {
settings: RepoSettings | null;
apiToken: string;
oss?: boolean;
proxyModel?: string;
dbSecrets?: Record<string, string>;
} | null;
if (data === null) {
@@ -86,8 +97,12 @@ export async function fetchRunContext(params: {
modes: data.settings?.modes ?? [],
setupScript: data.settings?.setupScript ?? null,
postCheckoutScript: data.settings?.postCheckoutScript ?? null,
prepushScript: data.settings?.prepushScript ?? null,
},
apiToken: data.apiToken,
oss: data.oss ?? false,
proxyModel: data.proxyModel,
dbSecrets: data.dbSecrets,
};
} catch {
clearTimeout(timeoutId);
+6
View File
@@ -12,6 +12,9 @@ export interface RunContextData {
};
repoSettings: RepoSettings;
apiToken: string;
oss: boolean;
proxyModel?: string | undefined;
dbSecrets?: Record<string, string> | undefined;
}
interface ResolveRunContextDataParams {
@@ -42,5 +45,8 @@ export async function resolveRunContextData(
},
repoSettings: runContext.settings,
apiToken: runContext.apiToken,
oss: runContext.oss,
proxyModel: runContext.proxyModel,
dbSecrets: runContext.dbSecrets,
};
}
-1
View File
@@ -64,7 +64,6 @@ export type SetupGitParams = GitContext;
* setup git configuration and authentication for the repository.
* - configures git identity (user.email, user.name)
* - sets up authentication via gitToken (minimal contents:write)
* - for PR events, checks out the PR branch using shared helper
*
* gitToken is a minimal-permission token (contents + workflows) used for git operations.
* it is assumed to be potentially exfiltratable, so it has limited scope.
+24
View File
@@ -0,0 +1,24 @@
import { spawnSync } from "node:child_process";
import { log } from "./cli.ts";
export function addSkill(params: {
ref: string;
skill: string;
env: Record<string, string>;
agent: string;
}): void {
const result = spawnSync(
"npx",
["skills", "add", params.ref, "--skill", params.skill, "-g", "-a", params.agent, "-y"],
{
env: { ...process.env, ...params.env },
stdio: "pipe",
timeout: 30_000,
}
);
if (result.status === 0) {
log.info(`installed ${params.skill} skill (${params.agent})`);
} else {
log.info(`${params.skill} skill install failed: ${(result.stderr?.toString() || "").trim()}`);
}
}
+19 -16
View File
@@ -76,7 +76,8 @@ export interface SpawnOptions {
env?: NodeJS.ProcessEnv;
input?: string;
timeout?: number;
// activity timeout: kill process if no stdout/stderr for this many ms (default: 30s, 0 to disable)
// activity timeout: kill process if no stdout for this many ms (default: 30s, 0 to disable).
// only stdout resets the timer — stderr (e.g. provider error retries) does not count as progress.
activityTimeout?: number;
cwd?: string;
stdio?: ("pipe" | "ignore" | "inherit")[];
@@ -95,7 +96,6 @@ export interface SpawnResult {
* Spawn a subprocess with streaming callbacks and buffered results
*/
export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
const { cmd, args, env, input, timeout, cwd, stdio, onStdout, onStderr } = options;
const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
installSignalHandler();
@@ -106,13 +106,13 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
return new Promise((resolve, reject) => {
// security: caller must provide complete env object, not merged with process.env
const child = nodeSpawn(cmd, args, {
env: env || {
const child = nodeSpawn(options.cmd, options.args, {
env: options.env || {
PATH: process.env.PATH || "",
HOME: process.env.HOME || "",
},
stdio: stdio || ["pipe", "pipe", "pipe"],
cwd: cwd || process.cwd(),
stdio: options.stdio || ["pipe", "pipe", "pipe"],
cwd: options.cwd || process.cwd(),
});
// track child for cleanup on Ctrl+C
@@ -125,7 +125,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
let lastActivityTime = performance.now();
// overall timeout
if (timeout) {
if (options.timeout) {
timeoutId = setTimeout(() => {
isTimedOut = true;
child.kill("SIGTERM");
@@ -135,12 +135,14 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
child.kill("SIGKILL");
}
}, 5000);
}, timeout);
}, options.timeout);
}
// activity timeout: kill if no output for too long
if (activityTimeoutMs > 0) {
log.debug(`spawn activity timer: pid=${child.pid} cmd=${cmd} timeout=${activityTimeoutMs}ms`);
log.debug(
`spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms`
);
activityCheckIntervalId = setInterval(() => {
const idleMs = performance.now() - lastActivityTime;
log.debug(
@@ -149,7 +151,9 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
if (idleMs > activityTimeoutMs) {
isActivityTimedOut = true;
const idleSec = Math.round(idleMs / 1000);
log.info(`no output for ${idleSec}s from pid=${child.pid} (${cmd}), killing process`);
log.info(
`no output for ${idleSec}s from pid=${child.pid} (${options.cmd}), killing process`
);
child.kill("SIGKILL");
clearInterval(activityCheckIntervalId);
}
@@ -165,16 +169,15 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
updateActivity();
const chunk = data.toString();
stdoutBuffer += chunk;
onStdout?.(chunk);
options.onStdout?.(chunk);
});
}
if (child.stderr) {
child.stderr.on("data", (data: Buffer) => {
updateActivity();
const chunk = data.toString();
stderrBuffer += chunk;
onStderr?.(chunk);
options.onStderr?.(chunk);
});
}
@@ -186,7 +189,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
if (isTimedOut) {
reject(new Error(`process timed out after ${timeout}ms`));
reject(new Error(`process timed out after ${options.timeout}ms`));
return;
}
@@ -222,8 +225,8 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
});
});
if (input && child.stdin && stdio?.[0] !== "ignore") {
child.stdin.write(input);
if (options.input && child.stdin && options.stdio?.[0] !== "ignore") {
child.stdin.write(options.input);
child.stdin.end();
}
});
+154
View File
@@ -0,0 +1,154 @@
import { log } from "./log.ts";
type TodoItem = {
id: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
};
function isValidTodoStatus(value: string): value is TodoItem["status"] {
return (
value === "pending" || value === "in_progress" || value === "completed" || value === "cancelled"
);
}
function parseTodowriteInput(input: unknown): { todos: unknown[]; merge: boolean } | undefined {
if (!input || typeof input !== "object" || !("todos" in input)) return undefined;
if (!Array.isArray(input.todos)) return undefined;
const merge = "merge" in input && input.merge === true;
return { todos: input.todos, merge };
}
function parseTodoItem(entry: unknown, index: number): TodoItem | undefined {
if (!entry || typeof entry !== "object") return undefined;
if (!("content" in entry) || typeof entry.content !== "string") return undefined;
const id = "id" in entry && typeof entry.id === "string" ? entry.id : String(index);
const status =
"status" in entry && typeof entry.status === "string" && isValidTodoStatus(entry.status)
? entry.status
: "pending";
return { id, content: entry.content, status };
}
function renderTodoMarkdown(todos: TodoItem[]): string {
return todos
.map((todo) => {
switch (todo.status) {
case "completed":
return `- [x] ${todo.content}`;
case "cancelled":
return `- ~~${todo.content}~~`;
case "in_progress":
return `- [ ] <img src="https://uploads.pullfrog.com/Progress%20Indicator.gif" width="11" style="visibility: visible; max-width: 100%;" /> ${todo.content}`;
case "pending":
return `- [ ] ${todo.content}`;
default:
todo.status satisfies never;
return `- [ ] ${todo.content}`;
}
})
.join("\n");
}
export type TodoTracker = {
update: (input: unknown) => void;
flush: () => Promise<void>;
cancel: () => void;
/** resolves when any in-flight onUpdate call completes */
settled: () => Promise<void>;
renderCollapsible: () => string;
readonly enabled: boolean;
/** true after the tracker has successfully called onUpdate at least once */
readonly hasPublished: boolean;
};
const DEBOUNCE_MS = 2000;
export function createTodoTracker(onUpdate: (body: string) => Promise<void>): TodoTracker {
const state = new Map<string, TodoItem>();
let enabled = true;
let hasPublished = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let inflightPromise: Promise<void> = Promise.resolve();
function scheduleUpdate() {
if (!enabled) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
if (!enabled || state.size === 0) return;
const markdown = renderTodoMarkdown(Array.from(state.values()));
inflightPromise = inflightPromise
.then(async () => {
if (!enabled) return;
await onUpdate(markdown);
hasPublished = true;
})
.catch((err) => {
log.debug(`todo progress update failed: ${err}`);
});
}, DEBOUNCE_MS);
}
return {
update(input: unknown) {
if (!enabled) return;
const parsed = parseTodowriteInput(input);
if (!parsed) return;
if (!parsed.merge) state.clear();
for (const [index, entry] of parsed.todos.entries()) {
const item = parseTodoItem(entry, index);
if (item) state.set(item.id, item);
}
log.debug(`» todowrite: ${state.size} items tracked`);
scheduleUpdate();
},
async flush() {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
if (!enabled || state.size === 0) return;
const markdown = renderTodoMarkdown(Array.from(state.values()));
inflightPromise = inflightPromise
.then(async () => {
if (!enabled) return;
await onUpdate(markdown);
hasPublished = true;
})
.catch((err) => {
log.debug(`todo progress flush failed: ${err}`);
});
await inflightPromise;
},
cancel() {
enabled = false;
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
},
async settled() {
await inflightPromise;
},
renderCollapsible(): string {
if (state.size === 0) return "";
const todos = Array.from(state.values());
const completed = todos.filter((t) => t.status === "completed").length;
const markdown = renderTodoMarkdown(todos);
return `<details>\n<summary>Task list (${completed}/${todos.length} completed)</summary>\n\n${markdown}\n\n</details>`;
},
get enabled() {
return enabled;
},
get hasPublished() {
return hasPublished;
},
};
}
+10
View File
@@ -0,0 +1,10 @@
import semver from "semver";
import packageJson from "../package.json" with { type: "json" };
export function getDevDependencyVersion(name: keyof typeof packageJson.devDependencies): string {
const version = packageJson.devDependencies[name];
if (!semver.valid(version)) {
throw new Error(`dev dependency "${name}" must be a pinned version, got "${version}"`);
}
return version;
}