SLOPSHOPPER

opencode-go

Lets Claude delegate tasks to OpenCode Go, Zen, Z.AI Coding Plan and Gemini (Google AI Pro, through Antigravity) models, picked in an OpenCode panel, have…

newpaneguardcommandtoaststatus
v0.11.2MITupdated 2026-10-26abharrison1995-droid/opencode-go-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · opencode-go
│ ┃ OpenCode ✕ › fix the failing auth test and add an audit log call │ ┃ THIS CHAT │ ┃ [ Claude ] [ OpenCode ] ⏺ Read(src/auth.ts) │ ┃ Claude answers and hands work to OpenCode ⎿ Read 6 lines │ ┃ with delegate. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ WORKER ⏺ Bash(bun test) │ ┃ opencode-go/kimi-k3 ⎿ 3 pass, 1 fail │ ┃ opencode-go/kimi-k3 │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ REASONING │ ┃ [ low ] [ medium ] [ high ] [ xhigh ] [ max ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ PRESETS › /opencode │ ┃ [ cheap ] [ reliable ] [ strong ] ⎿ opencode-go: OpenCode panel opened. Worker: opencode-go/kimi-k3 │ ┃ │ ┃ LOCAL │ ┃ ● Kev off Start │ ┃ Qwen3.5 + LoRA decision model (Jared Palmer) │ ┃ ● Laya off Start │ ┃ ModernBERT decision model, 100+ languages │ ┃ (Convai) │ ┃ │ ┃ historyToggle │ ┃ │ ┃ Search models or makers ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · OpenCode
THIS CHAT [ Claude ] [ OpenCode ] Claude answers and hands work to OpenCode with delegate. WORKER opencode-go/kimi-k3 opencode-go/kimi-k3 REASONING [ low ] [ medium ] [ high ] [ xhigh ] [ max ] PRESETS [ cheap ] [ reliable ] [ strong ] LOCAL ● Kev off Start Qwen3.5 + LoRA decision model (Jared Palmer) ● Laya off Start ModernBERT decision model, 100+ languages (Convai) historyToggle Search models or makers Loading models… ↻ Refresh models
README

opencode-go

A Claude Code mod that lets Claude delegate tasks to cheaper models through the opencode CLI: an OpenCode Go subscription, Zen's free models, a Z.AI Coding Plan if you have one, and Gemini on a Google AI Pro plan through the Antigravity CLI. Claude plans and reviews; workers do the routine work. Gemini can also take the planning and checking off Claude's hands (goal mode).

Requirements

  • The opencode CLI on your PATH (npm i -g opencode-ai), signed in with opencode auth login, choosing OpenCode Go. Zen's free models work without a subscription; a Z.AI Coding Plan is optional.
  • Optional, for Gemini: the Antigravity CLI (agy), signed in with the Google account that has Google AI Pro.
  • git, for delegate_batch worktrees.
  • For the local decision models only: git, uv, and a few GB of disk.

Install

At the prompt of a Claude Code terminal session:

/plugin install opencode-go --marketplace abharrison1995-droid/opencode-go-mod

Answer y to add the marketplace, then pick a scope.

From a shell:

claude plugin install opencode-go --marketplace abharrison1995-droid/opencode-go-mod

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with abharrison1995-droid/opencode-go-mod, then Add plugin.

Quick start

  1. npm i -g opencode-ai, then opencode auth login and choose OpenCode Go (run it again for a Z.AI Coding Plan).
  2. Install the mod as above and start a new Claude Code session. The OpenCode panel opens.
  3. Run /opencode selftest. Every check should pass; optional parts you have not set up (the Z.AI Coding Plan, Gemini, Kev, Laya) show as skipped.
  4. Pick a worker in the panel, or a preset, and ask Claude to hand it something routine.
  5. Optional: install the Antigravity CLI and run agy once to sign in, for Gemini (see below).
  6. Optional: /opencode local setup prints the command that installs Kev and Laya.

What it does

  • delegate runs one OpenCode worker and waits for its answer.
  • delegate_batch runs up to 12 tasks in parallel, each in its own git worktree, with dependsOn ordering.
  • Runs resume their session after rate limits, stalls and failures, up to autoRetries times, then try fallbackModel.
  • verify runs a shell command after each run and resumes the worker with the output until it passes.
  • shared context and files are passed to every batch task.
  • A task above 80% of the model's context is denied; above 50% it gets a warning.
  • maxCost limits a batch, and dailyBudget limits the day's spend.
  • Runs can be cancelled from the panel or with /opencode cancel.
  • Results come back short, with the full report in .opencode/reports/.
  • Each model is rated good, shaky or bad; unreliable models are flagged in the tool description.
  • Batch history and spend by model are kept.
  • A profile (cheap, reliable, strong, plan for the Z.AI Coding Plan, or gemini for Google AI Pro) sets a preset model and reasoning level.
  • autoMerge merges passing worktree tasks into oc/<batch>-merged; autoResolve has a task's own worker fix a merge conflict.
  • review has a free model check each task's diff, so Claude reads only flagged tasks.
  • The ladder profile starts on healthy free models and escalates, through Gemini and the Z.AI Coding Plan when connected, to paid ones only on failure.
  • Tasks with dependsOn build on their dependencies' branches.
  • The OpenCode panel and /opencode commands cover models, reasoning, presets, batches, history and cancel.
  • Chat mode answers turns through OpenCode Go's Anthropic-compatible endpoint.

Z.AI Coding Plan

A Z.AI (GLM) Coding Plan subscription works as a third source of workers beside OpenCode Go and Zen. Connect it to opencode once:

opencode auth login

Pick Z.AI Coding Plan (or Zhipu AI Coding Plan for a bigmodel.cn subscription) and paste the plan's API key. Then press ↻ Refresh models in the OpenCode panel: the GLM models appear under Z.AI PLAN, and delegate can run them by id, e.g. zai-coding-plan/glm-5.3.

Plan models have no per-token cost, so the daily budget never blocks them, but they are not treated as free either: they use the plan's 5-hour quota.

  • The plan preset and profile run on the plan's GLM-5.3 (its flash model when that is all there is).
  • The ladder profile tries the plan after the free models and before paid ones, while the plan is healthy and has quota.
  • The mod counts model calls (each step a worker takes) on plan models over the last 5 hours and shows them under Z.AI PLAN. Set zaiQuota to stop starting plan runs at a number of calls; the plan itself counts prompts, so pick it from your z.ai usage page. A run that hits the plan's own limit is rate-limited and falls back like any other.
  • Chat mode stays on Go models.

Gemini (Google AI Pro, through Antigravity)

A Google AI Pro plan's models run through the Antigravity CLI, agy, signed in with your Google account: no API key and no per-token cost, drawn from the plan's own quota. Install it once:

curl -fsSL https://antigravity.google/cli/install.sh | bash

Run agy once to sign in, then press ↻ Refresh models in the OpenCode panel. The plan's models appear under GEMINI with ids like agy/gemini-4-argon and agy/gemini-3.8-flash (Antigravity also offers Claude and GPT-OSS models on the plan, listed the same way). The reasoning level (low, medium, high) picks agy's matching model.

  • The gemini preset and profile run Gemini 4 Argon at high reasoning.
  • The ladder profile tries Gemini after the free models and before the Z.AI Coding Plan and paid ones.
  • readOnly runs agy's plan mode, which reads but edits nothing. Gemini's 1M-token context suits reading a whole codebase.
  • Batch reviews (review: true) use Gemini when it is connected (the reviewModel option's auto), else a free model.
  • A run that hits the plan's quota is rate-limited, resumes its agy conversation, and falls back like any other.

Goal mode: Gemini orchestrates and checks. Call delegate_batch with a goal instead of tasks. Gemini reads the folder and splits the goal into tasks (at most 6, with dependencies and verify commands); the workers run them; Gemini reviews each task's diff; passing tasks merge into oc/<batch>-merged; and Gemini checks the merged result against the goal. Claude makes one call and reads one report. profile: "gemini" runs the workers on Gemini too. checker (and checkerEffort) picks the model that plans, reviews and checks, e.g. agy/gemini-3.8-flash for a faster, lighter checker; the default is Gemini 4 Argon at high.

Headless agy cannot ask for permission, so the mod runs it with --dangerously-skip-permissions --sandbox. The sandbox keeps credential folders (such as ~/.docker and ~/.kube) away from its commands, but it does not stop writes elsewhere in your home folder: give editing tasks a git repo, where batches work in worktrees.

Local decision models (Kev, Laya)

decide asks a small model on your own machine typed questions about a piece of text: yes/no (noul), one-of (choice) or a scale (score). It returns calibrated probabilities, with nothing generated. It is free, private, and takes milliseconds once the model is loaded. Claude uses it for cheap, repeatable judgements such as triage, routing and classification.

ModelPortWhat it is
kev8009Kev, a LoRA on Qwen3.5. The default checkpoint is jaredpalmer/kev-0.8b, which fits a 4 GB GPU.
laya8010Laya, ModernBERT-based. English, plus 100+ languages.

Setup, once, with git and uv. /opencode local setup prints this command with your mod and models folders filled in:

scripts/setup-local.sh --dir ~/local-models

It clones Kev into kev/ with its venv in kev/.venv, and makes a laya-env/ venv with laya[serve]. --only kev or --only laya installs one, and --dry-run prints the steps. Run it again to update Kev. Weights download on each model's first start.

To run a model bigger than your GPU, set kevGpuGb to what the card can spare (about 4 on an 8 GB card with a desktop on it), and Kev splits itself between the GPU and RAM.

Models take turns: servers start one at a time, and model: "both" asks Kev and then Laya, never the two at once.

The first decide call starts that model's server on 127.0.0.1 and downloads its weights on first use. The server runs until you press Stop or the session ends. A server you started yourself on the same port is used as is.

How Claude uses it

Claude hands routine, well-specified work to delegate and keeps planning and review for itself. The worker sees nothing of the conversation, so the prompt must be self-contained, including files, constraints and what to report back. For independent work, or a larger job split into parts, Claude uses delegate_batch: workers run in parallel in separate worktrees, common context goes in shared, and dependsOn orders tasks. A task that would exceed 80% of its model's context is denied before it starts.

Claude gives coding tasks a verify command, such as npm test. If the run succeeds but the check fails, the worker is resumed with the output for up to verifyRounds rounds. A profile picks a preset model and reasoning level, and an explicit model or effort wins over it. By default the result is a short report and the full report is written to a file, readable inline with report: full. With autoMerge, tasks in worktrees whose verify passed (or that had no verify) are merged into oc/<batch>-merged without touching the user's checkout; conflicting tasks are reported and left unmerged.

The OpenCode panel

The panel opens when a session starts and with /opencode. It has these sections:

  • THIS CHAT: switches between Claude and OpenCode for chat mode.
  • WORKER: the picked model, its maker, and its price or free status, plus recent rate limits, hangs and failures when the model is shaky or bad.
  • SPENT: this chat's spend and run count, and the all-time total.
  • REASONING: the low, medium, high, xhigh and max levels.
  • PRESETS: the cheap, reliable and strong presets, plan when the Z.AI Coding Plan is connected and gemini when Gemini is, with a note for the matching one.
  • LOCAL: Kev and Laya, with status (off, starting, ready or error), call count, last latency, and Start or Stop.
  • AGENTS: a card for each running delegate job and running batch task, numbered in order. The header reads <n> : <MODEL> : <REASONING> in the model maker's colour. The stage line shows the stage (working, starting, retrying, verifying or reviewing), the step count, the try number above 1, and for batch tasks the batch title, task id and verify result. ACTIVITY shows the latest action. Each card has Cancel.
  • PLAN: the latest batches and their tasks, showing status, model, steps, attempts, verify result, merge state and branch. Tasks offer Diff, Merge, Discard and Resume where they apply. A diff opens in a preview.
  • HISTORY: recent finished batches with status, task count, cost and age, plus spend by model.
  • FREE, PAID, GEMINI and Z.AI PLAN: model lists grouped by maker. GEMINI appears when agy is signed in and shows its runs in the last 5 hours; Z.AI PLAN appears when the Coding Plan is connected and shows its model calls in the last 5 hours. Free models show whether they are Go or Zen; paid models show input/output price per Mtok. Each model name has a dot in its maker's colour, and health dots mark good, shaky and bad models. A search field filters by model or maker, and Refresh models reloads the lists.

On mobile the panel is compact: it shows the worker, reasoning and presets, recent models, and an All models toggle instead of the full lists.

Commands

CommandWhat it does
/opencodeOpens the panel.
/opencode model <name>Picks the worker model by id or name; several matches are listed.
/opencode effort <level>Sets reasoning to low, medium, high, xhigh or max.
`/opencode chat on\off`Sends this chat's turns through OpenCode Go, or back to Claude.
/opencode preset [name]Applies a preset (cheap, reliable, strong, plan) or lists them.
`/opencode models [free\paid\zai\gemini]`Lists models with free status or price.
`/opencode local [start\stop\setup] [kev\laya]`Shows, starts or stops the local decision models, or prints the command that installs them.
/opencode statusShows the worker, chat mode, running jobs and spend.
/opencode selftestChecks the CLI, git, hang detection, rate-limit detection, notifications, the report folder, which model sources opencode lists, and Kev and Laya.
/opencode batchesLists this chat's batches and their task states.
/opencode historyLists recent finished batches and spend by model.
`/opencode cancel <id>\all`Stops a job, a batch or every run.
/opencode diff <batch> <task>Shows a task branch's diff.
/opencode merge <batch> <task>Merges a task branch.
/opencode discard <batch> <task>Removes a task's worktree and branch.
/opencode resume <batch> <task>Continues a failed or cancelled task.
/opencode helpLists the commands.

Configuration

OptionWhat it doesDefault
defaultModelWorker used until one is picked in the OpenCode panel (provider/model, or a bare id under opencode-go/).opencode-go/kimi-k3
orchestrateAdds a short system prompt section telling Claude to hand routine work to OpenCode workers.true
fallbackModelModel a run switches to after the picked one keeps failing (429s, hangs). Empty: no fallback.opencode-go/deepseek-v4.1-flash
autoRetriesHow many times a failed or hung opencode run is resumed before giving up (and trying the fallback model).3
stallMinutesA run that prints nothing for this long is killed and resumed.10
reviewModelModel that reviews batch tasks when review is on (and in goal mode). auto: Gemini when connected, else opencode/deepseek-v4.1-flash-free.auto
dailyBudgetNo new paid runs start once today's OpenCode spend reaches this. 0: no limit.0
zaiQuotaZ.AI Coding Plan model calls per 5 hours after which no new plan runs start. 0: no limit.0
localModelsDirFolder holding kev/ (with its .venv) and laya-env/. Empty: ~/local-models.empty
kevModelCheckpoint Kev serves. jaredpalmer/kev-4b is more accurate: give it a GPU with about 16 GB, or split it with kevGpuGb.jaredpalmer/kev-0.8b
kevGpuGbAbove 0, Kev's first layers fill this many GiB of the GPU and the rest run from system RAM (through scripts/kev_split.py and Hugging Face accelerate). This lets a model larger than the card run, more slowly. 0: Kev runs whole on one device.0
localDeviceauto, cuda or cpu, for Kev.auto
layaDeviceauto, cuda or cpu, for Laya. CPU leaves the GPU to Kev; on an 8 GB card the two don't fit together.cpu

Change these with /plugin configure opencode-go@opencode-go-mod in a terminal session, or in the plugin settings in the desktop app.

Troubleshooting

  • 429 or 'Endpoint is unavailable': the endpoint is busy or rate-limited. Runs resume on their own; paid Go models may need Global regions enabled in the OpenCode workspace.
  • A run prints nothing for stallMinutes: it is killed and resumed.
  • /opencode selftest: checks the CLI, git, hang and rate-limit detection, notifications, the report folder, the model sources, and Kev and Laya.
  • A finished batch task marked 'no changes ⚠': it edited nothing in its worktree.
  • opencode missing or not signed in: it must be on PATH and signed in with opencode auth login.
  • No Z.AI PLAN section: run opencode auth login, pick Z.AI Coding Plan, then Refresh models.
  • No GEMINI section: check that agy models lists models in a terminal (run agy to sign in if it lists none), then Refresh models. agy update updates the CLI.
  • Kev or Laya 'is not installed': run /opencode local setup and the command it prints.

Model health

Each finished attempt is counted against its model as ok, failed, rate-limited or hung. Cancelled runs are ignored. A run whose verify still fails after its fix-up rounds counts as failed, since the model did not produce working code. The rating is the bad share: failed plus rateLimited plus hung, over all runs.

  • good: under 20% bad.
  • shaky: 20% up to (not including) 50% bad.
  • bad: 50% or more bad.

A record whose last run is more than 6 hours old is treated leniently: under 50% bad is good, and 50% or more is shaky, never bad.

Ratings show as the panel's health dots in the free and paid model lists, as Unreliable right now in the delegate tool description, and the ladder profile skips shaky and bad free models.

Developing

scripts/check.sh runs everything a release must pass: claude plugin validate ., claude plugin test ., the scripts' Python tests, and scripts/typecheck.sh. GitHub Actions runs all but the type-check on every push and pull request (the engine types it needs exist only where Claude Code has run). AGENTS.md describes the layout and the rules the validator enforces.

Source 20 files
hooks/register.tsx 1574 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Batch, BatchSummary, PlanUse, BatchTask, Phase, Catalog, DecideInput, DelegateBatchInput, DelegateInput, Effort, Job, LocalName, LocalServer, ModelHealth, Preset, Preview, Spend, WorkerModel } from '../types'
5import { batchSpecs, checkTasks, commitTask, prepareDir, rememberBatch, runBatch } from './batch'
6import type { BatchRunSpec, BatchTaskSpec, Isolation, RunnerDefaults, TaskResult } from './batch'
7import { costsMoney, findOpencode, fullId, isFree, isPlan, loadCatalog, price, readCache, worker, writeCache } from './catalog'
8import { checkPrompt, isAgyId, MAX_PLANNED, NO_GEMINI, parsePlan, pickChecker, planPrompt } from './gemini'
9import type { Plan } from './gemini'
10import { batchesText, diffText, effortText, HELP, historyText, localText, setupText, matchJob, matchModel, modelsText, parseArgs, presetsText, statusText } from './commands'
11import { chatTools, explainError, readKey, requestBody, sendWithRetry, toMessages } from './chat'
12import { cancel, track, tracked, untrack } from './control'
13import { attachedChars, budgetMessage, contextVerdict, contextWarning, dailySpent, formatReport, localDate, notify, overContext, resolveDir, runResilient } from './delegate'
14import { branchDiff, discardTask, mergeBranch, repoOf, worktreeFolder } from './git'
15import type { TaskAction } from './git'
16import { recordRun } from './health'
17import { ladder } from './ladder'
18import { askLocal, checkQuestions, formatAnswers, LOCAL, localNamed, ping, startLocal, stopLocal } from './local'
19import type { LocalOptions, LocalSpec } from './local'
20import type { Rung } from './ladder'
21import { panel } from './panel'
22import type { PanelView } from './panel'
23import { EFFORTS, geminiPreset, isEffort, NO_PLAN, planPreset, presetIn, presetNamed, presetsFor, rate } from './presets'
24import { batchReport, batchShortReport, changedFiles, delegateShort, writeReport } from './report'
25import type { ReportExtras } from './report'
26import { resolveConflict } from './resolve'
27import { reviewTask } from './review'
28import { formatChecks, selfTest } from './selftest'
29import { planCalls, prune, quotaMessage, resetAt, usedIn } from './quota'
30
31const catalog = atom({ plugin: 'opencode-go', key: 'catalog' } as const, { status: 'loading', models: [] } as Catalog)
32const picked = atom({ plugin: 'opencode-go', key: 'model' } as const, '')
33const effort = atom({ plugin: 'opencode-go', key: 'effort' } as const, 'medium' as Effort)
34const jobs = atom({ plugin: 'opencode-go', key: 'jobs' } as const, [] as Job[])
35const chat = atom({ plugin: 'opencode-go', key: 'chat' } as const, false)
36const chatSession = atom({ plugin: 'opencode-go', key: 'chatSession' } as const, '')
37const query = atom({ plugin: 'opencode-go', key: 'query' } as const, '')
38const open = atom({ plugin: 'opencode-go', key: 'open' } as const, {} as Record<string, boolean>)
39const batches = atom({ plugin: 'opencode-go', key: 'batches' } as const, [] as Batch[])
40const spend = atom({ plugin: 'opencode-go', key: 'spend' } as const, { session: 0, runs: 0 } as Spend)
41const recent = atom({ plugin: 'opencode-go', key: 'recent' } as const, [] as string[])
42const showAll = atom({ plugin: 'opencode-go', key: 'showAll' } as const, false)
43const health = atom({ plugin: 'opencode-go', key: 'health' } as const, {} as Record<string, ModelHealth>)
44const history = atom({ plugin: 'opencode-go', key: 'history' } as const, [] as BatchSummary[])
45const spendByModel = atom({ plugin: 'opencode-go', key: 'spendByModel' } as const, {} as Record<string, number>)
46const preview = atom({ plugin: 'opencode-go', key: 'preview' } as const, null as Preview | null)
47const local = atom({ plugin: 'opencode-go', key: 'local' } as const, {} as Record<string, LocalServer>)
48const planUse = atom({ plugin: 'opencode-go', key: 'planUse' } as const, [] as PlanUse[])
49const geminiUse = atom({ plugin: 'opencode-go', key: 'geminiUse' } as const, [] as PlanUse[])
50
51const TOOL = 'mcp__opencode-go__delegate'
52const BATCH_TOOL = 'mcp__opencode-go__delegate_batch'
53const DECIDE_TOOL = 'mcp__opencode-go__decide'
54const PANE = 'opencode'
55
56let sessionCwd = ''
57
58function describe(models: WorkerModel[], health: Record<string, ModelHealth> = {}) {
59  const { free, paid, plan, gemini } = worker(models)
60  const shown = paid.slice(0, 8).map(m => `${m.id} (${price(m.input)}/${price(m.output)})`).join(', ')
61  const more = paid.length > 8 ? `, and ${paid.length - 8} more in the OpenCode panel` : ''
62  const shaky = (id: string) => (rate(health[id]) === 'shaky' ? ' (shaky)' : '')
63  const unreliable = [...free, ...paid, ...plan, ...gemini].filter(m => rate(health[m.id]) === 'bad').map(m => m.id)
64  return `Delegate a self-contained task to a cheaper model through opencode (the user's OpenCode Go subscription, Zen's free models, or their Z.AI Coding Plan) or Antigravity (their Google AI Pro plan's Gemini models, agy/*), run as a full agent (it can read, edit and run commands in the given directory), and wait for its answer.
65
66Use it for routine, well-specified work: boilerplate, bulk edits, writing tests to a clear spec, summarising files, first drafts, mechanical refactors. Keep planning, judgement and review for yourself, and check what it changed (git diff, tests) before reporting success.
67
68The worker sees nothing of this conversation: put everything it needs in the prompt (files, constraints, acceptance criteria, what to report back). Pass files to attach them. Pass the returned sessionId to continue the same opencode session.
69
70Give every coding task a verify command (e.g. npm test) so failures are fixed by the worker, not by you. Runs resume themselves after rate limits, hangs and failures, and fall back to the fallback model. The user can cancel a run from the OpenCode panel.
71
72Set profile to cheap, reliable or strong to pick a preset model and reasoning level, plan for the user's Z.AI Coding Plan, gemini for Gemini on their Google AI Pro plan, or ladder to start on a healthy free model and escalate (through Gemini and the Z.AI Coding Plan, when connected) to paid ones if it fails (an explicit model or effort wins). Report defaults to short with the full report in a file; pass full to read it inline.
73
74Several delegate calls in one message may run one after another rather than together; for real parallel work use delegate_batch, which gives each worker its own folder.
75
76Set readOnly true for research, review or planning: it runs the read-only plan agent (opencode's or Antigravity's plan mode), which cannot edit files. Gemini's 1M-token context suits reading a whole codebase. A Gemini (agy/*) worker runs with its permission prompts skipped, as headless Antigravity needs.
77
78FREE: ${free.map(m => `${m.id}${shaky(m.id)}`).join(', ') || 'none right now'}
79PAID (cheapest first, $ in/out per Mtok): ${shown || 'none right now'}${more}${plan.length > 0 ? `\nZ.AI PLAN (the user's GLM Coding Plan: no per-token cost, draws on the plan's prompt quota): ${plan.map(m => `${m.id}${shaky(m.id)}`).join(', ')}` : ''}${gemini.length > 0 ? `\nGEMINI (the user's Google AI Pro plan via Antigravity: no per-token cost, draws on the plan's quota): ${gemini.map(m => `${m.id}${shaky(m.id)}`).join(', ')}` : ''}${unreliable.length > 0 ? `\nUnreliable right now: ${unreliable.join(', ')}` : ''}
80
81Do not pick a paid model other than the user's choice unless they ask. Paid models drain the user's Go allowance; Z.AI PLAN models drain the Coding Plan's quota; GEMINI models the Google AI Pro plan's.`
82}
83
84async function registerTool($: EngineInterface, models: WorkerModel[]) {
85  const healthNow = await read($, health)
86  await $.tool.register({
87    name: 'delegate',
88    description: describe(models, healthNow),
89    isDeferred: false,
90    inputSchema: {
91      type: 'object',
92      properties: {
93        prompt: { type: 'string', description: 'The complete, self-contained task for the worker model.' },
94        model: { type: 'string', description: 'provider/model (e.g. opencode-go/kimi-k3), or a bare id under opencode-go/. Default: the model picked in the OpenCode panel.' },
95        profile: { type: 'string', enum: ['cheap', 'reliable', 'strong', 'ladder', 'plan', 'gemini'], description: 'Preset model and reasoning level; an explicit model or effort wins.' },
96        effort: { type: 'string', enum: EFFORTS, description: 'Reasoning level. Default: the level picked in the OpenCode panel. Ignored by a model without that level.' },
97        dir: { type: 'string', description: 'Directory the worker runs in (absolute, or relative to the session). Default: the session directory.' },
98        sessionId: { type: 'string', description: 'An earlier sessionId from this tool, to continue that opencode session.' },
99        autoApprove: { type: 'boolean', description: 'Pass --auto so opencode auto-approves permission prompts that are not explicitly denied. Default false.' },
100        readOnly: { type: 'boolean', description: "Run opencode's read-only plan agent (--agent plan) for research, review or planning that must not edit files. Default false." },
101        files: { type: 'array', items: { type: 'string' }, description: 'Files to attach for the worker (-f each), absolute or relative to dir.' },
102        verify: { type: 'string', description: 'Shell command that checks the work, e.g. npm test. Strongly recommended: give every coding task a verify command so failures are fixed by the worker, not by you.' },
103        verifyRounds: { type: 'integer', minimum: 0, maximum: 5, description: 'How many times to resume the worker to fix a failed verify. Default 2.' },
104        report: { type: 'string', enum: ['short', 'full'], description: 'short (default): a compact summary with the full report in .opencode/reports. full: everything inline.' },
105      },
106      required: ['prompt'],
107    },
108  })
109
110  await $.tool.register({
111    name: 'delegate_batch',
112    description: `Runs several opencode workers at once, each in its own git worktree (or a copy) so they never edit the same files. Tasks may depend on each other and start in dependency order. The report gives each task's status, cost, folder/branch, git diff --stat, actions and answer; review their branches and merge what is good.
113
114Use it for independent pieces of work that can run in parallel, or for a larger job split into clear parts. Workers see nothing of this conversation: put common context in shared and each task's own work in its prompt. Pass files to attach them. Give every coding task a verify command so failures are fixed by the worker, not by you. maxCost stops the batch starting more tasks once it has cost that much. Set profile (cheap, reliable, strong, plan for the Z.AI Coding Plan, or ladder: healthy free models first, then the Z.AI Coding Plan when connected, escalating to paid) for preset models and reasoning; autoMerge merges passing worktree tasks into a branch of its own, autoResolve has workers fix their own merge conflicts, and review has a model (Gemini when connected, else a free one) check each task's diff so you read only flagged ones. A task with dependsOn starts from its dependencies' branches.
115
116Pass goal instead of tasks to hand the orchestration to Gemini (the user's Google AI Pro plan): it reads the folder and splits the goal into tasks, each task's diff is reviewed by Gemini, passing tasks are merged into oc/<batch>-merged, and Gemini checks the merged result against the goal. You read one report. review and autoMerge default to true in goal mode.
117
118Runs resume themselves after rate limits, hangs and failures, and fall back to the fallback model. The user can cancel from the OpenCode panel. Report defaults to short with the full report in a file; pass full to read it inline.
119
120With background true it returns at once and the report arrives as a new message when the batch ends.`,
121    isDeferred: false,
122    inputSchema: {
123      type: 'object',
124      properties: {
125        title: { type: 'string', description: 'A short name for the batch.' },
126        goal: { type: 'string', description: 'Instead of tasks: what the batch should achieve. Gemini plans the tasks, reviews each, and checks the merged result.' },
127        checker: { type: 'string', description: 'Model that plans (goal mode), reviews and checks, e.g. agy/gemini-3.8-flash. Default: Gemini 4 Argon when connected.' },
128        checkerEffort: { type: 'string', enum: EFFORTS, description: "The checker's reasoning level. Default: high." },
129        shared: { type: 'string', description: 'Common context prefixed to every task prompt.' },
130        files: { type: 'array', items: { type: 'string' }, description: 'Files attached to every task, absolute or relative to dir.' },
131        verify: { type: 'string', description: 'Default verify command for tasks without one. Strongly recommended for coding tasks.' },
132        verifyRounds: { type: 'integer', minimum: 0, maximum: 5, description: 'Default verify fix-up rounds. Default 2.' },
133        maxCost: { type: 'number', minimum: 0, description: 'USD; no more tasks start once the batch has cost this much.' },
134        profile: { type: 'string', enum: ['cheap', 'reliable', 'strong', 'ladder', 'plan', 'gemini'], description: 'Default preset model and reasoning for tasks; explicit model/effort wins.' },
135        autoResolve: { type: 'boolean', description: 'With autoMerge: a task that conflicts gets the merged branch brought into its worktree, its worker resolves the conflict, and it is merged. Default false.' },
136        review: { type: 'boolean', description: "After each task, a read-only reviewer (the reviewModel setting: Gemini when connected, else a free model) checks its diff against its prompt; the report flags concerns. Default false." },
137        autoMerge: { type: 'boolean', description: 'With worktree isolation, merge tasks whose verify passed (or that had none) into oc/<batch>-merged. Default false.' },
138        report: { type: 'string', enum: ['short', 'full'], description: 'short (default): a compact summary with the full report in .opencode/reports. full: everything inline.' },
139        tasks: {
140          type: 'array',
141          minItems: 1,
142          maxItems: 12,
143          items: {
144            type: 'object',
145            properties: {
146              id: { type: 'string', description: 'Unique id for this task in the batch.' },
147              prompt: { type: 'string', description: 'The complete, self-contained task for this worker.' },
148              title: { type: 'string', description: 'A short title for the report. Default: the id.' },
149              model: { type: 'string', description: 'provider/model, or a bare id under opencode-go/. Default: the panel model.' },
150              profile: { type: 'string', enum: ['cheap', 'reliable', 'strong', 'ladder', 'plan', 'gemini'], description: 'Preset model and reasoning for this task; an explicit model or effort wins.' },
151              effort: { type: 'string', enum: EFFORTS, description: 'Reasoning level. Default: the panel level. Ignored by a model without that level.' },
152              readOnly: { type: 'boolean', description: "Run the read-only plan agent (--agent plan) for this task." },
153              dependsOn: { type: 'array', items: { type: 'string' }, description: 'Ids of tasks in this batch that must finish (done) first.' },
154              files: { type: 'array', items: { type: 'string' }, description: 'Files to attach for this task, absolute or relative to dir.' },
155              verify: { type: 'string', description: 'Shell command that checks this task, e.g. npm test. Overrides the batch verify.' },
156              verifyRounds: { type: 'integer', minimum: 0, maximum: 5, description: 'How many times to resume this task to fix a failed verify.' },
157            },
158            required: ['id', 'prompt'],
159          },
160        },
161        maxParallel: { type: 'integer', minimum: 1, maximum: 6, description: 'How many workers may run at once (1..6). Default 3.' },
162        isolation: { type: 'string', enum: ['worktree', 'copy', 'none'], description: "How to keep workers from editing the same files. Default: worktree when dir is in a git repo, else none." },
163        dir: { type: 'string', description: 'Base folder (absolute, or relative to the session). Default: the session directory.' },
164        autoApprove: { type: 'boolean', description: 'Pass --auto so opencode auto-approves permission prompts that are not explicitly denied. Default false.' },
165        background: { type: 'boolean', description: 'Return at once and deliver the report as a new message when the batch ends. Default false.' },
166      },
167      required: ['title'],
168    },
169  })
170}
171
172async function refresh($: EngineInterface) {
173  const previous = await read($, catalog)
174  const cached = previous.models.length === 0 ? await readCache($) : undefined
175  if (cached !== undefined) {
176    await update($, catalog, () => ({ status: 'loading', models: cached }))
177    await registerTool($, cached)
178  } else {
179    await update($, catalog, c => ({ ...c, status: 'loading' as const }))
180  }
181  const loaded = await loadCatalog($)
182  if (loaded.status === 'ready') {
183    await update($, catalog, () => loaded)
184    await writeCache($, loaded.models)
185    await registerTool($, loaded.models)
186    return
187  }
188  const models = cached ?? previous.models
189  await update($, catalog, () => ({ status: 'error' as const, models, error: loaded.error }))
190  if (models.length > 0) await registerTool($, models)
191}
192
193// Continues a failed or cancelled batch task's opencode session in its folder (the panel's Resume).
194export async function resumeTask($: EngineInterface, batchId: string, taskId: string) {
195  const held = batchSpecs(batchId)
196  const list = await read($, batches)
197  const batch = list.find(b => b.id === batchId)
198  const task = batch?.tasks.find(t => t.id === taskId)
199  if (batch === undefined || task === undefined) {
200    $.ui.toast(`No batch task ${batchId}/${taskId}.`)
201    return
202  }
203  if (task.status !== 'failed' && task.status !== 'cancelled' && task.status !== 'skipped') {
204    $.ui.toast(`${taskId} is ${task.status}; only failed, cancelled or skipped tasks can resume.`)
205    return
206  }
207  if (task.sessionId === undefined && task.dir === undefined) {
208    $.ui.toast(`${taskId} has no session or folder to resume.`)
209    return
210  }
211  const spec = held?.specs.find(t => t.id === taskId)
212  if (spec === undefined) {
213    $.ui.toast(`No saved prompt for ${batchId}/${taskId}.`)
214    return
215  }
216  const bin = await findOpencode($)
217  if (bin === undefined) {
218    $.ui.toast('The opencode CLI is not installed or not on PATH.')
219    return
220  }
221
222  const patch = (change: Partial<BatchTask>) =>
223    update($, batches, all =>
224      all.map(b => (b.id === batchId ? { ...b, tasks: b.tasks.map(t => (t.id === taskId ? { ...t, ...change } : t)) } : b)),
225    )
226  const dir = task.dir ?? sessionCwd
227  const key = `task:${batchId}:${taskId}`
228  const signal = track(key)
229  $.ui.toast(`Resuming ${taskId}…`)
230
231  void (async () => {
232    await patch({ status: 'running', error: undefined, verify: undefined })
233    await showStatus($)
234    try {
235      const outcome = await runResilient(
236        $,
237        {
238          bin,
239          model: task.model,
240          fallback: held?.defaults.fallback,
241          level: task.effort,
242          prompt: spec.prompt,
243          cwd: dir,
244          readOnly: spec.readOnly,
245          files: spec.files,
246          sessionId: task.sessionId,
247          retries: held?.defaults.retries ?? 3,
248          stallMs: held?.defaults.stallMs ?? 600_000,
249          signal,
250          verify: spec.verify,
251          verifyRounds: spec.verifyRounds ?? 2,
252          onAttempt: async info => {
253            await patch({ attempts: info.attempt, model: info.model })
254            await showStatus($)
255          },
256          onStage: async stage => {
257            await patch({ stage })
258            await showStatus($)
259          },
260        },
261        async run => {
262          const last = run.tools.at(-1)
263          await patch(last === undefined ? { steps: run.tools.length } : { steps: run.tools.length, last })
264          await showStatus($)
265        },
266      )
267      await addSpend($, outcome.run.cost, outcome.model, outcome.run.calls)
268      if (task.branch !== undefined) await commitTask($, dir, `opencode ${batchId}/${taskId}: ${task.title}`)
269
270      const failed = outcome.reason !== 'ok'
271      const error =
272        !failed
273          ? undefined
274          : outcome.verify !== undefined && !outcome.verify.passed
275            ? `verify: FAILED after ${outcome.verify.rounds ?? 0} rounds\n${outcome.verify.output.slice(-1500)}`
276            : outcome.run.errors.join('\n') || undefined
277      await patch({
278        status: outcome.reason === 'cancelled' ? 'cancelled' : failed ? 'failed' : 'done',
279        model: outcome.model,
280        cost: task.cost + outcome.run.cost,
281        steps: outcome.run.tools.length,
282        last: outcome.run.tools.at(-1),
283        sessionId: outcome.sessionId,
284        attempts: outcome.attempts,
285        verify: outcome.verify === undefined ? undefined : outcome.verify.passed ? 'passed' : 'failed',
286        error,
287      })
288      await showStatus($)
289      $.ui.toast(`${taskId}: ${outcome.reason === 'cancelled' ? 'cancelled' : failed ? 'still failing' : 'done'} on resume.`)
290    } catch (err) {
291      await patch({ status: 'failed', error: reason(err) })
292      await showStatus($)
293      $.ui.toast(`${taskId}: resume failed: ${reason(err)}`)
294    } finally {
295      untrack(key)
296    }
297  })()
298}
299
300async function current($: EngineInterface, defaultModel: string) {
301  return (await read($, picked)) || defaultModel
302}
303
304async function registerDecide($: EngineInterface) {
305  await $.tool.register({
306    name: 'decide',
307    description: `Ask a small decision model running on this machine (free, private, no network) typed questions about a piece of text, and get calibrated probabilities back in well under a second once loaded. Nothing is generated, so nothing is hallucinated: each answer is a probability over the options you give.
308
309Models: kev (Kev, a Qwen3.5 decision model; the more accurate on unfamiliar questions) and laya (Laya, a ModernBERT decision model; faster, English plus 100+ languages, 512-token English limit). model "both" asks Kev, then Laya (one after the other), and shows them side by side. The first call starts the server, which can take a minute or more (longer on the very first run, which downloads weights).
310
311Question types, keyed by a name you choose:
312- noul: yes/no. {"type":"noul","instructions":"Is this about billing?"} → probability of yes
313- choice: one of several. {"type":"choice","instructions":"Which team?","criteria":{"billing":"charges, refunds","shipping":"delivery"}}
314- score: an ordered scale. {"type":"score","instructions":"How urgent?","criteria":["low","normal","high"]}
315
316Use it for cheap, repeatable judgements: triage, routing, classifying files or messages, flagging risk, checking a rule against a document. Questions cannot read each other. Treat confidence below about 0.6 as unsure and decide those yourself. Accuracy is well below yours, so don't use it for anything that needs reasoning.`,
317    isDeferred: false,
318    inputSchema: {
319      type: 'object',
320      properties: {
321        model: { type: 'string', enum: ['kev', 'laya', 'both'], description: 'Which local model answers. Default kev.' },
322        state: { description: 'The text (or a JSON object) the questions are about.' },
323        questions: { type: 'object', description: 'Name → {type: noul|choice|score, instructions, criteria?}.' },
324        raw: { type: 'boolean', description: 'Return the full JSON answers, with every probability, instead of one line per question.' },
325      },
326      required: ['state', 'questions'],
327    },
328  })
329}
330
331async function homeDir($: EngineInterface) {
332  const { stdout } = await $.process.run(['printenv', 'HOME'])
333  return stdout.trim()
334}
335
336async function setLocal($: EngineInterface, name: LocalName, patch: Partial<LocalServer>) {
337  await update($, local, all => ({ ...all, [name]: { ...(all[name] ?? { status: 'off' }), ...patch } }))
338}
339
340// `cache` holds the folder once found, so HOME is read only the first time.
341async function localOpts($: EngineInterface, options: Parameters<Register>[1], cache: { dir: string }): Promise<LocalOptions> {
342  if (cache.dir === '') cache.dir = `${await homeDir($)}/local-models`
343  return { dir: cache.dir, kevRun: String(options.kevModel ?? '') || 'jaredpalmer/kev-0.8b', device: String(options.localDevice ?? '') || 'auto', layaDevice: String(options.layaDevice ?? '') || 'cpu', kevGpuGb: Number(options.kevGpuGb ?? 0) || 0, root: $.plugin.root }
344}
345
346async function bringUp($: EngineInterface, spec: LocalSpec, opts: LocalOptions) {
347  return startLocal($, spec, opts, patch => setLocal($, spec.name, patch))
348}
349
350async function refreshLocal($: EngineInterface) {
351  for (const spec of LOCAL) {
352    const now = (await read($, local))[spec.name]?.status
353    if (now === 'starting') continue
354    const up = await ping($, spec)
355    if (up && now !== 'ready') await setLocal($, spec.name, { status: 'ready', error: undefined })
356    if (!up && now === 'ready') await setLocal($, spec.name, { status: 'off' })
357  }
358}
359
360async function askOne($: EngineInterface, spec: LocalSpec, opts: LocalOptions, state: unknown, questions: unknown, raw: boolean) {
361  const up = await bringUp($, spec, opts)
362  if (!up.ok) return `${spec.label}: could not start. ${up.error}${up.error.includes('not installed') ? '' : `\nIs it installed in ${opts.dir}? (/opencode local status)`}`
363  const t0 = Date.now()
364  const got = await askLocal($, spec, state, questions)
365  if ('error' in got) return `${spec.label}: ${got.error}`
366  const ms = got.latencyMs ?? Date.now() - t0
367  await setLocal($, spec.name, { status: 'ready', calls: ((await read($, local))[spec.name]?.calls ?? 0) + 1, lastMs: ms })
368  return `${spec.label} (${Math.round(ms)} ms)\n${raw ? JSON.stringify(got.answers, null, 2) : formatAnswers(got.answers)}`
369}
370
371async function pick($: EngineInterface, id: string) {
372  await update($, picked, () => id)
373  await $.store.set('model', id)
374  const list = [id, ...(await read($, recent)).filter(r => r !== id)].slice(0, 5)
375  await update($, recent, () => list)
376  await $.store.set('recent', list)
377}
378
379async function pickEffort($: EngineInterface, level: Effort) {
380  await update($, effort, () => level)
381  await $.store.set('effort', level)
382}
383
384async function setChat($: EngineInterface, on: boolean, model: string) {
385  await update($, chat, () => on)
386  if (on && (await read($, chatSession)) === '') await update($, chatSession, () => crypto.randomUUID())
387  $.ui.toast(on ? `This chat now runs on ${model}.` : 'This chat is back on Claude.')
388}
389
390// One line for every delegate job and batch task, so parallel calls do not clear each other.
391async function showStatus($: EngineInterface) {
392  const running = await read($, jobs)
393  const tasks = (await read($, batches)).flatMap(b => b.tasks.filter(t => t.status === 'running'))
394  const retry = (attempt: number | undefined) => (attempt !== undefined && attempt > 1 ? ` (retry ${attempt})` : '')
395  const all = [
396    ...running.map(j => ({ model: j.model, steps: j.steps, last: j.last, attempt: j.attempt })),
397    ...tasks.map(t => ({ model: t.model, steps: t.steps, last: t.last, attempt: t.attempts })),
398  ]
399  const one = all.length === 1 ? all[0] : undefined
400  if (all.length === 0) {
401    $.ui.status(undefined)
402  } else if (one !== undefined) {
403    const short = one.model.split('/')[1] ?? one.model
404    $.ui.status(`opencode ${short}: ${one.steps} steps${retry(one.attempt)}${one.last ? ` · ${one.last.slice(0, 50)}` : ''}`)
405  } else {
406    const parts = all.map(r => `${r.model.split('/')[1] ?? r.model}:${r.steps}${retry(r.attempt)}`).join(', ')
407    $.ui.status(`opencode: ${all.length} running · ${parts}`)
408  }
409}
410
411// Whether the zaiQuota option leaves room for another Z.AI Coding Plan run.
412async function planRoom($: EngineInterface, quota: number): Promise<boolean> {
413  return quota === 0 || usedIn(await read($, planUse), Date.now()) < quota
414}
415
416async function addSpend($: EngineInterface, cost: number, model: string, calls?: Record<string, number>) {
417  const onPlan = planCalls(calls)
418  if (onPlan > 0) {
419    const now = Date.now()
420    await update($, planUse, u => [...prune(u, now), { at: now, calls: onPlan }])
421    await $.store.set('planUse', await read($, planUse))
422  }
423  const onGemini = Object.entries(calls ?? {}).reduce((n, [id, c]) => n + (isAgyId(id) ? c : 0), 0) || (calls === undefined && isAgyId(model) ? 1 : 0)
424  if (onGemini > 0) {
425    const now = Date.now()
426    await update($, geminiUse, u => [...prune(u, now), { at: now, calls: onGemini }])
427    await $.store.set('geminiUse', await read($, geminiUse))
428  }
429  await update($, spend, s => ({ session: s.session + cost, runs: s.runs + 1 }))
430  await update($, spendByModel, m => ({ ...m, [model]: (m[model] ?? 0) + cost }))
431  await $.store.set('spendByModel', await read($, spendByModel))
432  const total = await $.store.get('spendTotal')
433  await $.store.set('spendTotal', (typeof total === 'number' ? total : 0) + cost)
434  const day = localDate(Date.now())
435  const spent = await dailySpent($, day)
436  await $.store.set('spendDaily', { date: day, usd: spent + cost })
437}
438
439function taskId() {
440  return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
441}
442
443function reason(err: unknown) {
444  if (err instanceof Error) return err.message
445  const message = (err as { message?: unknown } | undefined)?.message
446  return typeof message === 'string' ? message : String(err)
447}
448
449function batchId() {
450  return `b${Math.random().toString(36).slice(2, 8).padEnd(6, '0').slice(0, 6)}`
451}
452
453function isHealthMap(v: unknown): v is Record<string, ModelHealth> {
454  if (v === null || typeof v !== 'object' || Array.isArray(v)) return false
455  return Object.values(v).every(h => h !== null && typeof h === 'object' && !Array.isArray(h))
456}
457
458function isNumberMap(v: unknown): v is Record<string, number> {
459  if (v === null || typeof v !== 'object' || Array.isArray(v)) return false
460  return Object.values(v).every(n => typeof n === 'number')
461}
462
463async function applyPreset($: EngineInterface, preset: Preset) {
464  await pick($, preset.model)
465  await pickEffort($, preset.effort)
466}
467
468// The panel's Diff/Merge/Discard buttons and /opencode diff|merge|discard share these.
469async function taskAction($: EngineInterface, kind: TaskAction, batchId: string, taskId: string): Promise<{ ok: boolean; text: string }> {
470  const batch = (await read($, batches)).find(b => b.id === batchId)
471  if (batch === undefined) return { ok: false, text: `No batch ${batchId}.` }
472  const task = batch.tasks.find(t => t.id === taskId)
473  if (task === undefined) return { ok: false, text: `No task ${taskId} in batch ${batchId}.` }
474
475  const repo = repoOf(batch, task)
476
477  if (kind === 'diff') {
478    if (task.branch === undefined || repo.top === undefined) {
479      return { ok: false, text: `${batchId}/${taskId} has no branch to diff.` }
480    }
481    const diff = await branchDiff($, repo.top, repo.base ?? 'HEAD', task.branch)
482    await update($, preview, () => ({ title: `${task.id} diff`, text: diff.text }))
483    return diff
484  }
485
486  if (kind === 'merge') {
487    if (task.status !== 'done' || task.branch === undefined) return { ok: false, text: `${batchId}/${taskId} has no finished branch to merge.` }
488    if (task.merged === true) return { ok: true, text: `${batchId}/${taskId} is already merged.` }
489    if (repo.top === undefined) return { ok: false, text: `Batch ${batchId} has no repo top to merge into.` }
490    const merged = await mergeBranch($, repo.top, task.branch)
491    if (merged.ok) {
492      await update($, batches, list =>
493        list.map(b => (b.id === batchId ? { ...b, tasks: b.tasks.map(t => (t.id === taskId ? { ...t, merged: true } : t)) } : b)),
494      )
495    }
496    $.ui.toast(merged.ok ? `Merged ${task.branch}.` : `Merge failed: ${merged.text.split('\n')[0] ?? ''}`)
497    return merged
498  }
499
500  if (task.branch === undefined) return { ok: false, text: `${batchId}/${taskId} has no branch to discard.` }
501  if (batch.status === 'running') return { ok: false, text: `Batch ${batchId} is still running.` }
502  if (repo.top === undefined) return { ok: false, text: `Batch ${batchId} has no repo top to discard from.` }
503  const discarded = await discardTask($, repo.top, worktreeFolder(repo.top, batch.id, task.id), task.branch)
504  if (discarded.ok) {
505    await update($, batches, list =>
506      list.map(b =>
507        b.id === batchId
508          ? {
509              ...b,
510              tasks: b.tasks.map(t => {
511                if (t.id !== taskId) return t
512                const next = { ...t, last: 'discarded' }
513                delete next.branch
514                return next
515              }),
516            }
517          : b,
518      ),
519    )
520  }
521  $.ui.toast(discarded.ok ? `Discarded ${batchId}/${taskId}.` : `Discard failed: ${discarded.text.split('\n')[0] ?? ''}`)
522  return discarded
523}
524
525async function noteHealth($: EngineInterface, model: string, reason: string, ms: number, error?: string) {
526  if (reason === 'cancelled') return
527  // Parallel tasks finish together: update in place so no record is lost.
528  await update($, health, h => recordRun(h, model, reason, ms, error))
529  await $.store.set('health', await read($, health))
530}
531
532async function addHistory($: EngineInterface, batch: Batch) {
533  const entry: BatchSummary = {
534    id: batch.id,
535    title: batch.title,
536    status: batch.status,
537    startedAt: batch.startedAt,
538    endedAt: batch.endedAt ?? Date.now(),
539    cost: batch.tasks.reduce((sum, t) => sum + t.cost, 0),
540    tasks: batch.tasks.length,
541    done: batch.tasks.filter(t => t.status === 'done').length,
542    reportPath: batch.reportPath,
543  }
544  await update($, history, list => [...list, entry].slice(-30))
545  await $.store.set('history', await read($, history))
546}
547
548// Ladder rungs after the first, as runResilient fallbacks, each with a level its model offers.
549function laterRungs(rungs: Rung[], models: WorkerModel[]) {
550  if (rungs.length < 2) return undefined
551  return rungs.slice(1).map(rung => {
552    const known = models.find(m => m.id === rung.model)
553    const fits = rung.effort !== undefined && (known === undefined || known.efforts.includes(rung.effort))
554    return { model: rung.model, level: fits ? rung.effort : undefined, retries: rung.retries }
555  })
556}
557
558async function git($: EngineInterface, argv: string[]) {
559  return $.process.run(argv).catch(err => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
560}
561
562// Merges passing task branches into oc/<id>-merged through a temporary worktree, so the
563// user's checkout and current branch never move.
564async function autoMerge(
565  $: EngineInterface,
566  batch: Batch,
567  extras: ReportExtras,
568  resolve?: (task: BatchTask, files: string[]) => Promise<boolean>,
569) {
570  if (batch.top === undefined) return
571  const top = batch.top
572  const branch = `oc/${batch.id}-merged`
573  const base = batch.base ?? 'HEAD'
574  const made = await git($, ['git', '-C', top, 'branch', branch, base])
575  if (made.exitCode !== 0) return
576  batch.integration = branch
577
578  const folder = worktreeFolder(top, batch.id, 'merged')
579  const added = await git($, ['git', '-C', top, 'worktree', 'add', folder, branch])
580  if (added.exitCode !== 0) return
581
582  try {
583    for (const task of batch.tasks) {
584      if (task.status !== 'done' || task.branch === undefined || task.verify === 'failed') continue
585      const merged = await git($, ['git', '-C', folder, 'merge', '--no-ff', '--no-edit', task.branch])
586      if (merged.exitCode === 0) {
587        task.merged = true
588        extras.merged ??= []
589        extras.merged.push(task.id)
590        continue
591      }
592      const conflicts = await git($, ['git', '-C', folder, 'diff', '--name-only', '--diff-filter=U'])
593      const files = conflicts.exitCode === 0 ? conflicts.stdout.split('\n').map(line => line.trim()).filter(line => line !== '') : []
594      await git($, ['git', '-C', folder, 'merge', '--abort'])
595      if (resolve !== undefined && (await resolve(task, files))) {
596        const again = await git($, ['git', '-C', folder, 'merge', '--no-ff', '--no-edit', task.branch])
597        if (again.exitCode === 0) {
598          task.merged = true
599          extras.merged ??= []
600          extras.merged.push(task.id)
601          extras.resolved ??= []
602          extras.resolved.push(task.id)
603          continue
604        }
605        await git($, ['git', '-C', folder, 'merge', '--abort'])
606      }
607      extras.conflicts ??= {}
608      extras.conflicts[task.id] = files
609    }
610  } finally {
611    await git($, ['git', '-C', top, 'worktree', 'remove', '--force', folder])
612  }
613}
614
615// Aborts when either signal does.
616function either(a: AbortSignal | undefined, b: AbortSignal): AbortSignal {
617  if (a === undefined) return b
618  const both = new AbortController()
619  const stop = () => both.abort()
620  if (a.aborted || b.aborted) stop()
621  a.addEventListener('abort', stop)
622  b.addEventListener('abort', stop)
623  return both.signal
624}
625
626// The checker's work (planning, a review, the final check) as a card in the panel's AGENTS,
627// cancellable like a delegate job. fn gets the card's abort signal.
628async function asJob<T>($: EngineInterface, o: { model: string; effort?: string; stage: Phase; last: string }, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
629  const job: Job = { id: taskId(), model: o.model, effort: o.effort, steps: 0, stage: o.stage, last: o.last }
630  await update($, jobs, list => [...list, job])
631  await showStatus($)
632  const signal = track(job.id)
633  try {
634    return await fn(signal)
635  } finally {
636    untrack(job.id)
637    await update($, jobs, list => list.filter(j => j.id !== job.id))
638    await showStatus($)
639  }
640}
641
642// Goal mode: Gemini reads the folder and splits the goal into batch tasks.
643async function planGoal(
644  $: EngineInterface,
645  o: { goal: string; title: string; shared?: string; verify?: string; dir: string; checker?: { model: string; effort: Effort }; retries: number; stallMs: number },
646): Promise<{ plan: Plan; model: string } | string> {
647  const checker = o.checker
648  if (checker === undefined) return NO_GEMINI
649  return asJob($, { model: checker.model, effort: checker.effort, stage: 'planning', last: `planning ${o.title}` }, async signal => {
650    const outcome = await runResilient(
651      $,
652      {
653        bin: '',
654        model: checker.model,
655        level: checker.effort,
656        prompt: planPrompt(o.goal, { shared: o.shared, verify: o.verify, maxTasks: MAX_PLANNED }),
657        cwd: o.dir,
658        readOnly: true,
659        retries: o.retries,
660        stallMs: o.stallMs,
661        signal,
662        verifyRounds: 0,
663      },
664      () => undefined,
665    )
666    await addSpend($, outcome.run.cost, outcome.model, outcome.run.calls)
667    if (outcome.reason === 'cancelled') return 'Planning was cancelled.'
668    if (outcome.reason !== 'ok') {
669      const why = (outcome.run.errors.join('\n') || outcome.stderr || outcome.run.text).trim().slice(0, 500)
670      return `Gemini could not plan the goal (${outcome.reason})${why ? `: ${why}` : '.'}`
671    }
672    const plan = parsePlan(outcome.run.text)
673    return typeof plan === 'string' ? `${plan} Its answer began: ${outcome.run.text.slice(0, 300)}` : { plan, model: outcome.model }
674  })
675}
676
677// Goal mode's last step: Gemini checks the merged branch as a whole against the goal.
678async function checkMerged(
679  $: EngineInterface,
680  o: { goal: string; top: string; base: string; integration: string; checker?: { model: string; effort: Effort }; retries: number; stallMs: number; signal?: AbortSignal },
681) {
682  const checker = o.checker
683  if (checker === undefined) return undefined
684  return asJob($, { model: checker.model, effort: checker.effort, stage: 'checking', last: 'checking the merged result' }, async signal => {
685    const review = await reviewTask($, {
686      bin: '',
687      dir: o.top,
688      base: o.base,
689      rev: `${o.base}...${o.integration}`,
690      prompt: o.goal,
691      ask: diff => checkPrompt(o.goal, diff),
692      model: checker.model,
693      level: checker.effort,
694      retries: o.retries,
695      stallMs: o.stallMs,
696      signal: either(o.signal, signal),
697    })
698    await addSpend($, review.cost, review.model)
699    return review
700  })
701}
702
703function planText(plan: Plan, model: string, goal: string) {
704  const tasks = plan.tasks.map(t => `- ${t.id}: ${t.title}${t.dependsOn.length > 0 ? ` (after ${t.dependsOn.join(', ')})` : ''}`).join('\n')
705  return `## Plan (${model})\nGoal: ${goal.length > 300 ? `${goal.slice(0, 300)}…` : goal}\n${plan.summary ? `${plan.summary}\n` : ''}${tasks}`
706}
707
708function checkText(check: { verdict: string; notes: string; model: string } | undefined, why?: string) {
709  if (check === undefined) return why === undefined ? '' : `## Final check\n${why}`
710  const head = check.verdict === 'ok' ? 'OK' : check.verdict === 'concerns' ? 'CONCERNS' : 'could not run'
711  return `## Final check (${check.model}): ${head}${check.notes ? `\n${check.notes}` : ''}`
712}
713
714export const register: Register = (on, options) => {
715  const defaultModel = fullId(String(options.defaultModel ?? 'opencode-go/kimi-k3'))
716  const localDir = { dir: String(options.localModelsDir ?? '').trim() }
717
718  on('session.start', async ($, e, next) => {
719    const started = await next(e)
720    sessionCwd = e.cwd
721    const storedModel = await $.store.get('model')
722    const storedEffort = await $.store.get('effort')
723    if (typeof storedModel === 'string' && (await read($, picked)) === '') await update($, picked, () => storedModel)
724    if (EFFORTS.includes(storedEffort as Effort)) await update($, effort, () => storedEffort as Effort)
725    const storedRecent = await $.store.get('recent')
726    if (Array.isArray(storedRecent) && storedRecent.every(r => typeof r === 'string') && (await read($, recent)).length === 0) {
727      await update($, recent, () => storedRecent as string[])
728    }
729    const storedHealth = await $.store.get('health')
730    if (isHealthMap(storedHealth) && Object.keys(await read($, health)).length === 0) await update($, health, () => storedHealth)
731    const storedHistory = await $.store.get('history')
732    if (Array.isArray(storedHistory) && (await read($, history)).length === 0) await update($, history, () => storedHistory as BatchSummary[])
733    const storedPlanUse = await $.store.get('planUse')
734    if (Array.isArray(storedPlanUse) && (await read($, planUse)).length === 0) {
735      const uses = storedPlanUse.filter((u): u is PlanUse => typeof u?.at === 'number' && typeof u?.calls === 'number')
736      await update($, planUse, () => prune(uses, Date.now()))
737    }
738    const storedGeminiUse = await $.store.get('geminiUse')
739    if (Array.isArray(storedGeminiUse) && (await read($, geminiUse)).length === 0) {
740      const uses = storedGeminiUse.filter((u): u is PlanUse => typeof u?.at === 'number' && typeof u?.calls === 'number')
741      await update($, geminiUse, () => prune(uses, Date.now()))
742    }
743    const storedSpendByModel = await $.store.get('spendByModel')
744    if (isNumberMap(storedSpendByModel) && Object.keys(await read($, spendByModel)).length === 0) {
745      await update($, spendByModel, () => storedSpendByModel)
746    }
747
748    await registerTool($, [])
749    await registerDecide($)
750    void refreshLocal($)
751    await $.command.register({ name: 'opencode', description: 'OpenCode panel and settings: /opencode [model <name> | effort <level> | chat on|off | models | status | help]' })
752    void refresh($)
753    void $.ui.open({ id: PANE, title: 'OpenCode' })
754    return started
755  })
756
757  on('command.run', { command: 'opencode' }, async ($, e) => {
758    const { sub, rest } = parseArgs(e.args)
759
760    const models = async () => {
761      if ((await read($, catalog)).models.length === 0) await refresh($)
762      return (await read($, catalog)).models
763    }
764
765    if (sub === '') {
766      if ((await read($, catalog)).models.length === 0) void refresh($)
767      await $.ui.open({ id: PANE, title: 'OpenCode' })
768      const isChat = await read($, chat)
769      return { text: `OpenCode panel opened. Worker: ${await current($, defaultModel)} · ${await read($, effort)}${isChat ? ' · chat mode on' : ''}` }
770    }
771
772    if (sub === 'help') return { text: HELP }
773
774    if (sub === 'selftest') {
775      const local = await localOpts($, options, localDir)
776      return { text: formatChecks(await selfTest($, { bin: await findOpencode($), dir: sessionCwd, models: await models(), local })) }
777    }
778
779    if (sub === 'model') {
780      if (rest === '') return { text: 'Usage: /opencode model <name>.' }
781      const { free, paid, plan } = worker(await models())
782      const found = matchModel([...free, ...paid, ...plan], rest, fullId)
783      if ('text' in found) return found
784      await pick($, found.model.id)
785      return { text: `Worker: ${found.model.name} (${found.model.id})` }
786    }
787
788    if (sub === 'effort') {
789      if (!EFFORTS.includes(rest as Effort)) return { text: `Reasoning must be one of ${EFFORTS.join(', ')}.` }
790      const level = rest as Effort
791      await pickEffort($, level)
792      const pickedId = await current($, defaultModel)
793      return { text: effortText(level, (await read($, catalog)).models.find(m => m.id === pickedId)) }
794    }
795
796    if (sub === 'chat') {
797      const want = rest.toLowerCase()
798      if (want !== 'on' && want !== 'off') return { text: 'Usage: /opencode chat on|off.' }
799      const model = await current($, defaultModel)
800      if (want === 'on' && !model.startsWith('opencode-go/')) {
801        return { text: `Chat mode needs a Go model. ${model} is not one: Zen models are served only to OpenCode's own app, and Z.AI Coding Plan models run through delegate. Pick a Go model with /opencode model <name>.` }
802      }
803      await setChat($, want === 'on', model)
804      return { text: want === 'on' ? `Chat mode on: this chat now runs on ${model}.` : 'Chat mode off: this chat is back on Claude.' }
805    }
806
807    if (sub === 'models') {
808      const { free, paid, plan, gemini } = worker(await models())
809      return { text: modelsText(free, paid, plan, rest, gemini) }
810    }
811
812    if (sub === 'local') {
813      const [verb = 'status', which = 'all'] = rest.toLowerCase().split(/\s+/).filter(Boolean)
814      if (verb === 'setup') {
815        const opts = await localOpts($, options, localDir)
816        return { text: setupText($.plugin.root, opts.dir, which === 'all' ? undefined : which) }
817      }
818      const specs = which === 'all' ? LOCAL : [localNamed(which)].filter((x): x is LocalSpec => x !== undefined)
819      if (specs.length === 0) return { text: `No local model "${which}". Try kev or laya.` }
820      if (verb === 'start') {
821        const opts = await localOpts($, options, localDir)
822        for (const spec of specs) void bringUp($, spec, opts)
823        return { text: `Starting ${specs.map(x => x.label).join(' and ')}; the panel shows when ${specs.length > 1 ? 'they are' : 'it is'} ready.` }
824      }
825      if (verb === 'stop') {
826        const lines: string[] = []
827        for (const spec of specs) {
828          const stopped = await stopLocal(spec.name)
829          if (stopped) await setLocal($, spec.name, { status: 'off', error: undefined })
830          lines.push(stopped ? `${spec.label} stopped.` : `${spec.label} was not started by this session.`)
831        }
832        return { text: lines.join('\n') }
833      }
834      await refreshLocal($)
835      return { text: localText(specs, await read($, local)) }
836    }
837
838    if (sub === 'status') {
839      const money = await read($, spend)
840      return {
841        text: statusText({
842          model: await current($, defaultModel),
843          level: await read($, effort),
844          isChat: await read($, chat),
845          running: await read($, jobs),
846          batches: await read($, batches),
847          session: money.session,
848          runs: money.runs,
849          total: Number((await $.store.get('spendTotal')) ?? 0) || 0,
850        }),
851      }
852    }
853
854    if (sub === 'preset') {
855      const list = async (prefix: string) => ({ text: presetsText(prefix, await current($, defaultModel), await read($, effort), presetsFor((await read($, catalog)).models)) })
856      if (rest === '') return list('Presets:')
857      const preset = presetIn((await read($, catalog)).models, rest)
858      if (preset === undefined) return list(`Unknown preset "${rest}". Presets:`)
859      await applyPreset($, preset)
860      return { text: `Preset ${preset.name}: ${preset.model} · ${preset.effort}` }
861    }
862
863    if (sub === 'batches') return { text: batchesText(await read($, batches)) }
864
865    if (sub === 'cancel') {
866      if (rest === '') return { text: 'Usage: /opencode cancel <id>|all.' }
867      if (rest.toLowerCase() === 'all') {
868        const ids = tracked()
869        const stopped = ids.filter(id => cancel(id)).length
870        return { text: stopped > 0 ? `Cancelled ${stopped} run${stopped === 1 ? '' : 's'}.` : 'Nothing was running.' }
871      }
872      const batch = (await read($, batches)).find(b => b.id === rest)
873      if (batch !== undefined) {
874        const stopped = cancel(`batch:${batch.id}`)
875        return { text: stopped || batch.status === 'running' ? `Cancelling batch ${batch.id}.` : `Batch ${batch.id} is not running.` }
876      }
877      const found = matchJob(await read($, jobs), rest)
878      if ('text' in found) return found
879      return { text: cancel(found.job.id) ? `Cancelling ${found.job.id}.` : `${found.job.id} is not running.` }
880    }
881
882    if (sub === 'merge' || sub === 'discard' || sub === 'diff' || sub === 'resume') {
883      const parts = rest.split(/\s+/).filter(p => p !== '')
884      const batch = parts[0]
885      const task = parts[1]
886      if (batch === undefined || task === undefined) return { text: `Usage: /opencode ${sub} <batch> <task>.` }
887      if (sub === 'resume') {
888        await resumeTask($, batch, task)
889        return { text: `Resuming ${batch}/${task}.` }
890      }
891      const result = await taskAction($, sub, batch, task)
892      return { text: sub === 'diff' ? diffText(result.text) : result.text }
893    }
894
895    if (sub === 'history') return { text: historyText(await read($, history), await read($, spendByModel)) }
896
897    return { text: `Unknown subcommand. ${HELP}` }
898  })
899
900  on('prompt.compose', async ($, e, next) => {
901    const composed = await next(e)
902    if (options.orchestrate === false || (await read($, chat))) return composed
903    const text = `# Delegating to OpenCode
904The user has OpenCode workers (Go, Zen and any Z.AI Coding Plan models) wired in through the ${TOOL} tool. Act as the orchestrator: plan, split work into independent, clearly specified pieces, hand routine pieces to OpenCode models with ${TOOL} (or ${BATCH_TOOL} to run several at once, each in its own git worktree), and review their output yourself. Do the hard reasoning, architecture and final verification yourself. Do small one-step edits directly when explaining them would cost more than doing them.
905The user's chosen worker is ${await current($, defaultModel)} at ${await read($, effort)} reasoning (set in the OpenCode panel).`
906    return { sections: [...composed.sections, { id: 'opencode-go:orchestrate', text, scope: 'session' as const }] }
907  })
908
909  // Chat mode: the main loop's model requests go to the picked OpenCode Go model instead of Claude.
910  on('turn.step', async function* ($, e, next) {
911    if (e.agentId !== undefined || !(await read($, chat))) return yield* next(e)
912
913    const modelId = await current($, defaultModel)
914    const stop = (text: string) => ({ kind: 'text' as const, index: 0, text })
915    const failed = { turnId: e.turnId, index: e.index, toolUses: [], stopReason: 'end_turn' as const, usage: null }
916    if (!modelId.startsWith('opencode-go/')) {
917      const text = `OpenCode chat mode needs a Go model. ${modelId} is not one: Zen models are served only to OpenCode's own app, and Z.AI Coding Plan models run through delegate. Pick a Go model in the OpenCode panel.`
918      yield stop(text)
919      return { ...failed, answer: text }
920    }
921    const key = await readKey($)
922    if (key === undefined) {
923      const text = 'OpenCode chat mode found no OpenCode key. Run `opencode auth login` and choose OpenCode Go.'
924      yield stop(text)
925      return { ...failed, answer: text }
926    }
927
928    const transcript = await $.session.messages()
929    if ('deny' in transcript) {
930      const text = `OpenCode chat mode could not read the conversation: ${transcript.deny}`
931      yield stop(text)
932      return { ...failed, answer: text }
933    }
934    const tools = await chatTools($)
935    const { sections } = await $.prompt.compose({ tools: tools.map(t => t.name) })
936    const system = sections.map(s => s.text).join('\n\n')
937    const model = (await read($, catalog)).models.find(m => m.id === modelId)
938    const body = requestBody(modelId, model, await read($, effort), system, tools, toMessages(transcript))
939
940    const short = modelId.split('/')[1]
941    $.ui.status(`OpenCode chat: ${short} thinking…`)
942    const sent = await sendWithRetry($, key, await read($, chatSession), body, next.signal).finally(() => $.ui.status(undefined))
943    if ('aborted' in sent) return { ...failed, answer: '' }
944    if ('error' in sent) {
945      const text = `OpenCode request failed (${short}): ${explainError(sent.error.status, sent.error.message)}`
946      yield stop(text)
947      return { ...failed, answer: text }
948    }
949
950    const { response } = sent
951    let index = 0
952    let answer = ''
953    const toolUses: { name: string; input: unknown }[] = []
954    for (const block of response.content ?? []) {
955      if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
956        answer += block.text
957        yield { kind: 'text', index: index++, text: block.text }
958      } else if (block.type === 'tool_use' && typeof block.name === 'string') {
959        // Gateway ids vary by model; give each call an id the Anthropic API would also accept.
960        const id = `toolu_oc${crypto.randomUUID().replace(/-/g, '').slice(0, 22)}`
961        toolUses.push({ name: block.name, input: block.input ?? {} })
962        yield { kind: 'tool', index, id, name: block.name }
963        yield { kind: 'input', index: index++, json: JSON.stringify(block.input ?? {}) }
964      }
965      // Thinking blocks are left out: they carry no Anthropic signature, and the transcript must
966      // stay valid for Claude when the chat switches back.
967    }
968    const u = response.usage ?? {}
969    const usage = {
970      model: response.model ?? short ?? modelId,
971      input_tokens: u.input_tokens ?? 0,
972      output_tokens: u.output_tokens ?? 0,
973      cache_read_input_tokens: u.cache_read_input_tokens ?? 0,
974      cache_creation_input_tokens: u.cache_creation_input_tokens ?? 0,
975    }
976    const stopReason = toolUses.length > 0 ? 'tool_use' : response.stop_reason === 'max_tokens' ? 'max_tokens' : 'end_turn'
977    yield { kind: 'stop', stopReason, usage }
978    return { turnId: e.turnId, index: e.index, answer, toolUses, stopReason, usage }
979  })
980
981  on('tool.call', { tool: DECIDE_TOOL }, async ($, e) => {
982    const input: DecideInput = e
983    if (input.state === undefined || input.state === '') return { deny: 'decide needs a state: the text the questions are about.' }
984    const bad = checkQuestions(input.questions)
985    if (bad) return { deny: `decide: ${bad}` }
986    const specs = input.model === 'both' ? LOCAL : LOCAL.filter(x => x.name === (localNamed(input.model ?? '')?.name ?? 'kev'))
987    const opts = await localOpts($, options, localDir)
988    // One model after the other, never both at once: they share the machine's GPU and RAM.
989    const parts: string[] = []
990    for (const spec of specs) parts.push(await askOne($, spec, opts, input.state, input.questions, input.raw === true))
991    return { result: parts.join('\n\n') }
992  }).catch(($, e, next) => ({
993    deny: 'decide failed unexpectedly. Try /opencode local status.',
994  }))
995
996  on('tool.call', { tool: TOOL }, async ($, e, next) => {
997    const input: DelegateInput = e
998    if (typeof input.prompt !== 'string' || input.prompt.trim() === '') return { deny: 'delegate needs a prompt.' }
999
1000    const bin = await findOpencode($)
1001    if (bin === undefined) {
1002      return { deny: 'The opencode CLI is not installed or not on PATH. Ask the user to install it (npm i -g opencode-ai) and run `opencode auth login`, choosing OpenCode Go.' }
1003    }
1004
1005    const allModels = (await read($, catalog)).models
1006    if (input.profile === 'plan' && !input.model && planPreset(allModels) === undefined) return { deny: NO_PLAN }
1007    if (input.profile === 'gemini' && !input.model && geminiPreset(allModels) === undefined) return { deny: NO_GEMINI }
1008    const planOk = await planRoom($, Number(options.zaiQuota ?? 0) || 0)
1009    const rungs = input.profile === 'ladder' && !input.model ? ladder(allModels, await read($, health), planOk) : []
1010    const profile = typeof input.profile === 'string' ? presetIn(allModels, input.profile) ?? (input.profile === 'ladder' ? presetNamed('reliable') : undefined) : undefined
1011    const model = input.model ? fullId(input.model) : rungs[0]?.model ?? profile?.model ?? (await current($, defaultModel))
1012    const wanted = input.effort ?? rungs[0]?.effort ?? profile?.effort ?? (await read($, effort))
1013    const known = allModels.find(m => m.id === model)
1014    const level = known === undefined || known.efforts.includes(wanted) ? wanted : undefined
1015
1016    const dir = resolveDir(sessionCwd, input.dir)
1017    const base = dir ?? sessionCwd
1018    const files = (Array.isArray(input.files) ? input.files : [])
1019      .filter(file => typeof file === 'string' && file.trim() !== '')
1020      .map(file => resolveDir(base, file))
1021      .filter((file): file is string => file !== undefined)
1022
1023    const budget = Number(options.dailyBudget ?? 0) || 0
1024    if (budget > 0 && known !== undefined && costsMoney(known)) {
1025      const spent = await dailySpent($, localDate(Date.now()))
1026      if (spent >= budget) return { deny: budgetMessage(budget, spent) }
1027    }
1028    const quota = Number(options.zaiQuota ?? 0) || 0
1029    if (quota > 0 && known !== undefined && isPlan(known)) {
1030      const uses = await read($, planUse)
1031      const now = Date.now()
1032      if (usedIn(uses, now) >= quota) return { deny: quotaMessage(usedIn(uses, now), quota, resetAt(uses, now)) }
1033    }
1034
1035    const verdict = contextVerdict(input.prompt.length + (await attachedChars($, files)), known)
1036    if (verdict !== undefined && verdict.level === 'over') return { deny: overContext(verdict) }
1037    const warning = verdict !== undefined && verdict.level === 'warn' ? contextWarning(verdict) : undefined
1038
1039    const verify = typeof input.verify === 'string' && input.verify.trim() !== '' ? input.verify : undefined
1040    const verifyRounds = typeof input.verifyRounds === 'number' && Number.isFinite(input.verifyRounds) ? Math.max(0, Math.round(input.verifyRounds)) : 2
1041    const retries = Math.max(0, Math.round(Number(options.autoRetries ?? 3) || 0))
1042    const stallMs = Math.max(1, (Number(options.stallMinutes ?? 10) || 10) * 60_000)
1043    const fallbackSetting = String(options.fallbackModel ?? '')
1044    const fallback = fallbackSetting === '' ? undefined : fullId(fallbackSetting)
1045
1046    const job: Job = { id: taskId(), model, effort: level, steps: 0, attempt: 1 }
1047    const setJob = (patch: Partial<Job>) => update($, jobs, list => list.map(j => (j.id === job.id ? { ...j, ...patch } : j)))
1048    await update($, jobs, list => [...list, job])
1049    await showStatus($)
1050
1051    const signal = track(job.id)
1052    let attemptModel = model
1053    let attemptAt = Date.now()
1054    try {
1055      const outcome = await runResilient(
1056        $,
1057        {
1058          bin,
1059          model,
1060          fallback,
1061          fallbacks: laterRungs(rungs, allModels),
1062          firstRetries: rungs[0]?.retries,
1063          level,
1064          prompt: input.prompt,
1065          cwd: base,
1066          autoApprove: input.autoApprove,
1067          readOnly: input.readOnly,
1068          files,
1069          sessionId: input.sessionId,
1070          retries,
1071          stallMs,
1072          signal,
1073          verify,
1074          verifyRounds,
1075          onAttempt: async info => {
1076            if (info.attempt > 1) {
1077              await noteHealth($, attemptModel, info.previous, Date.now() - attemptAt)
1078              attemptModel = info.model
1079              attemptAt = Date.now()
1080            }
1081            await setJob({ attempt: info.attempt, model: info.model })
1082            await showStatus($)
1083          },
1084          onStage: async stage => {
1085            await setJob({ stage })
1086            await showStatus($)
1087          },
1088        },
1089        async run => {
1090          const last = run.tools.at(-1)
1091          await setJob(last === undefined ? { steps: run.tools.length } : { steps: run.tools.length, last })
1092          await showStatus($)
1093        },
1094      )
1095
1096      const failure = [...outcome.run.errors, outcome.stderr].filter(text => text.trim() !== '').join('\n')
1097      await noteHealth($, attemptModel, outcome.reason, Date.now() - attemptAt, outcome.reason === 'ok' ? undefined : failure)
1098      await addSpend($, outcome.run.cost, outcome.model, outcome.run.calls)
1099      const used = (await read($, catalog)).models.find(m => m.id === outcome.model)
1100      const short = outcome.model.split('/')[1] ?? outcome.model
1101      const same = outcome.model === model
1102      const full = formatReport({
1103        model: outcome.model,
1104        level: same ? level : undefined,
1105        wanted: same ? wanted : undefined,
1106        short,
1107        run: outcome.run,
1108        end: outcome.end,
1109        stderr: outcome.stderr,
1110        free: used !== undefined && isFree(used),
1111        attempts: outcome.attempts,
1112        verify: outcome.verify,
1113        warning,
1114      })
1115      const path = await writeReport($, base, `d-${job.id}`, full)
1116      const filesChanged = await changedFiles($, base)
1117      const report =
1118        input.report === 'full'
1119          ? full
1120          : delegateShort({
1121              model: outcome.model,
1122              level: same ? level : undefined,
1123              wanted: same ? wanted : undefined,
1124              short,
1125              run: outcome.run,
1126              end: outcome.end,
1127              reason: outcome.reason,
1128              stderr: outcome.stderr,
1129              free: used !== undefined && isFree(used),
1130              attempts: outcome.attempts,
1131              verify: outcome.verify,
1132              warning,
1133              files: filesChanged,
1134              path,
1135            })
1136      if (outcome.reason === 'cancelled') return { deny: `opencode run cancelled.\n\n${report}` }
1137      return outcome.reason === 'ok' ? { result: report } : { deny: `opencode run failed.\n\n${report}` }
1138    } finally {
1139      untrack(job.id)
1140      await update($, jobs, list => list.filter(j => j.id !== job.id))
1141      await showStatus($)
1142    }
1143  }).catch(($, e, next) => ({
1144    deny: `opencode-go: delegate failed: ${reason(next.error)}`,
1145  }))
1146
1147  on('tool.call', { tool: BATCH_TOOL }, async ($, e, next) => {
1148    type TaskSpec = BatchTaskSpec & { profile?: string }
1149    const input: DelegateBatchInput = e
1150    if (typeof input.title !== 'string' || input.title.trim() === '') return { deny: 'delegate_batch needs a title.' }
1151    let specs: TaskSpec[] = Array.isArray(input.tasks) ? input.tasks : []
1152    const goal = typeof input.goal === 'string' && input.goal.trim() !== '' ? input.goal.trim() : undefined
1153    if (goal === undefined && specs.length === 0) return { deny: 'delegate_batch needs tasks, or a goal for Gemini to plan.' }
1154    let planned: { plan: Plan; model: string } | undefined
1155    const checkerEffort = isEffort(input.checkerEffort) ? input.checkerEffort : undefined
1156    const checker = pickChecker((await read($, catalog)).models, input.checker, checkerEffort)
1157    if (goal !== undefined && specs.length === 0) {
1158      const got = await planGoal($, {
1159        goal,
1160        title: input.title,
1161        shared: typeof input.shared === 'string' ? input.shared : undefined,
1162        verify: typeof input.verify === 'string' && input.verify.trim() !== '' ? input.verify : undefined,
1163        dir: resolveDir(sessionCwd, input.dir) ?? sessionCwd,
1164        checker,
1165        retries: Math.max(0, Math.round(Number(options.autoRetries ?? 3) || 0)),
1166        stallMs: Math.max(1, (Number(options.stallMinutes ?? 10) || 10) * 60_000),
1167      })
1168      if (typeof got === 'string') return { deny: got }
1169      planned = got
1170      specs = got.plan.tasks.map(t => ({ ...t }))
1171    }
1172    // Goal mode hands the checking to Gemini too, unless the call says otherwise.
1173    const wantReview = input.review ?? planned !== undefined
1174    const wantMerge = input.autoMerge ?? planned !== undefined
1175    if (specs.some(t => t === null || typeof t !== 'object')) return { deny: 'Every task needs an id.' }
1176    const problem = checkTasks(specs)
1177    if (problem !== undefined) return { deny: problem }
1178
1179    const bin = await findOpencode($)
1180    if (bin === undefined) {
1181      return { deny: 'The opencode CLI is not installed or not on PATH. Ask the user to install it (npm i -g opencode-ai) and run `opencode auth login`, choosing OpenCode Go.' }
1182    }
1183
1184    const base = resolveDir(sessionCwd, input.dir) ?? sessionCwd
1185    let isolation: Isolation = input.isolation ?? 'none'
1186    if (input.isolation === undefined) {
1187      const found = await $.process.run(['git', '-C', base, 'rev-parse', '--show-toplevel']).catch(() => undefined)
1188      if (found !== undefined && found.exitCode === 0 && found.stdout.trim() !== '') isolation = 'worktree'
1189    }
1190    const parallel = typeof input.maxParallel === 'number' && Number.isFinite(input.maxParallel) ? Math.round(input.maxParallel) : 3
1191    const maxParallel = Math.max(1, Math.min(6, parallel))
1192
1193    const panelModel = await current($, defaultModel)
1194    const panelEffort = await read($, effort)
1195    const models = (await read($, catalog)).models
1196    if ((input.profile === 'plan' || specs.some(t => t.profile === 'plan' && !t.model)) && planPreset(models) === undefined) return { deny: NO_PLAN }
1197    if ((input.profile === 'gemini' || specs.some(t => t.profile === 'gemini' && !t.model)) && geminiPreset(models) === undefined) return { deny: NO_GEMINI }
1198    const healthNow = await read($, health)
1199    const planOk = await planRoom($, Number(options.zaiQuota ?? 0) || 0)
1200    const ladders = new Map<string, Rung[]>()
hooks/batch.ts 292 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Batch, BatchTask, Effort } from '../types'
4
5export type BatchTaskSpec = {
6  id: string
7  prompt: string
8  title?: string
9  model?: string
10  effort?: Effort
11  readOnly?: boolean
12  dependsOn?: string[]
13  verify?: string
14  verifyRounds?: number
15  files?: string[]
16}
17
18export type Isolation = 'worktree' | 'copy' | 'none'
19
20export type BatchRunSpec = { batch: Batch; maxParallel: number; signal?: AbortSignal }
21
22export type TaskVerify = { passed: boolean; output: string; rounds?: number }
23
24export type TaskResult = {
25  status: 'done' | 'failed' | 'skipped' | 'cancelled'
26  cost: number
27  steps: number
28  last?: string
29  sessionId?: string
30  dir?: string
31  branch?: string
32  error?: string
33  attempts?: number
34  verify?: TaskVerify
35}
36
37export type BatchExtras = {
38  isolation?: Isolation
39  top?: string
40  branch?: string
41  diffs?: Record<string, string>
42  answers?: Record<string, string>
43  warnings?: Record<string, string>
44}
45
46export type RunnerDefaults = { fallback?: string; retries: number; stallMs: number }
47
48const remembered = new Map<string, { specs: BatchTaskSpec[]; defaults: RunnerDefaults }>()
49
50// Task prompts are not on the panel's Batch, so the panel's Resume reads them from here.
51export function rememberBatch(id: string, specs: readonly BatchTaskSpec[], defaults: RunnerDefaults) {
52  remembered.set(id, {
53    specs: specs.map(t => ({
54      ...t,
55      dependsOn: t.dependsOn === undefined ? undefined : [...t.dependsOn],
56      files: t.files === undefined ? undefined : [...t.files],
57    })),
58    defaults,
59  })
60  while (remembered.size > 20) {
61    const oldest = remembered.keys().next().value
62    if (oldest === undefined) break
63    remembered.delete(oldest)
64  }
65}
66
67export function batchSpecs(id: string): { specs: BatchTaskSpec[]; defaults: RunnerDefaults } | undefined {
68  return remembered.get(id)
69}
70
71const MAX_TASKS = 12
72
73export function checkTasks(tasks: readonly BatchTaskSpec[]): string | undefined {
74  if (tasks.length === 0) return 'delegate_batch needs at least one task.'
75  if (tasks.length > MAX_TASKS) return `delegate_batch takes at most ${MAX_TASKS} tasks.`
76  const ids = new Set<string>()
77  for (const task of tasks) {
78    if (typeof task.id !== 'string' || task.id.trim() === '') return 'Every task needs an id.'
79    if (ids.has(task.id)) return `Duplicate task id: ${task.id}.`
80    ids.add(task.id)
81    if (typeof task.prompt !== 'string' || task.prompt.trim() === '') return `Task ${task.id} needs a prompt.`
82  }
83  for (const task of tasks) {
84    for (const dep of task.dependsOn ?? []) {
85      if (!ids.has(dep)) return `Task ${task.id} depends on unknown task ${dep}.`
86    }
87  }
88  const byId = new Map(tasks.map(t => [t.id, t]))
89  const visiting = new Set<string>()
90  const seen = new Set<string>()
91  const cyclic = (id: string): boolean => {
92    if (visiting.has(id)) return true
93    if (seen.has(id)) return false
94    visiting.add(id)
95    for (const dep of byId.get(id)?.dependsOn ?? []) if (cyclic(dep)) return true
96    visiting.delete(id)
97    seen.add(id)
98    return false
99  }
100  for (const task of tasks) if (cyclic(task.id)) return 'The tasks have a dependency cycle.'
101  return undefined
102}
103
104async function git($: EngineInterface, argv: string[]) {
105  return $.process.run(argv).catch(err => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
106}
107
108// Parallel `git worktree add` calls race for the repo's lock, so folders are made one at a time.
109let preparing: Promise<unknown> = Promise.resolve()
110
111export function prepareDir(
112  $: EngineInterface,
113  mode: Isolation,
114  base: string,
115  batchId: string,
116  taskId: string,
117  from: readonly string[] = [],
118): Promise<{ dir: string; branch?: string }> {
119  const made = preparing.then(() => makeDir($, mode, base, batchId, taskId, from))
120  preparing = made.catch(() => undefined)
121  return made
122}
123
124async function makeDir(
125  $: EngineInterface,
126  mode: Isolation,
127  base: string,
128  batchId: string,
129  taskId: string,
130  from: readonly string[],
131): Promise<{ dir: string; branch?: string }> {
132  if (mode === 'none') return { dir: base }
133
134  if (mode === 'copy') {
135    const dir = `${base}-opencode/${batchId}-${taskId}`
136    const made = await git($, ['mkdir', '-p', dir])
137    if (made.exitCode !== 0) throw new Error(made.stderr.trim() || `mkdir -p ${dir} failed`)
138    const copied = await git($, ['cp', '-a', `${base}/.`, dir])
139    if (copied.exitCode !== 0) throw new Error(copied.stderr.trim() || `cp -a ${base} failed`)
140    return { dir }
141  }
142
143  const found = await git($, ['git', '-C', base, 'rev-parse', '--show-toplevel'])
144  if (found.exitCode !== 0) throw new Error(found.stderr.trim() || `git -C ${base} rev-parse --show-toplevel failed`)
145  const top = found.stdout.trim()
146  const folder = `${top}-opencode/${batchId}-${taskId}`
147  const branch = `oc/${batchId}-${taskId}`
148  const added = await git($, ['git', '-C', top, 'worktree', 'add', '-b', branch, folder, from[0] ?? 'HEAD'])
149  if (added.exitCode !== 0) throw new Error(added.stderr.trim() || `git worktree add failed for ${folder}`)
150  for (const b of from.slice(1)) {
151    const merged = await git($, [
152      'git', '-C', folder, '-c', 'user.name=OpenCode worker', '-c', 'user.email=opencode-worker@localhost',
153      'merge', '--no-ff', '--no-edit', b,
154    ])
155    if (merged.exitCode === 0) continue
156    // Read the conflict list before the abort clears it.
157    const conflicts = await git($, ['git', '-C', folder, 'diff', '--name-only', '--diff-filter=U'])
158    await git($, ['git', '-C', folder, 'merge', '--abort'])
159    await git($, ['git', '-C', top, 'worktree', 'remove', '--force', folder])
160    await git($, ['git', '-C', top, 'branch', '-D', branch])
161    const files = conflicts.stdout.trim()
162    throw new Error(`dependencies ${from[0]} and ${b} conflict${files === '' ? '' : `: ${files.split('\n').filter(l => l.trim() !== '').join(', ')}`}`)
163  }
164  const rel = base === top ? '' : base.startsWith(`${top}/`) ? base.slice(top.length + 1) : ''
165  return { dir: rel ? `${folder}/${rel}` : folder, branch }
166}
167
168// Commits what a worker left in its worktree, so its branch holds the work and
169// `git diff main...<branch>` shows it, new files included. Returns the commit's --stat.
170export async function commitTask($: EngineInterface, dir: string, message: string): Promise<string | undefined> {
171  const status = await git($, ['git', '-C', dir, 'status', '--porcelain'])
172  if (status.exitCode !== 0 || status.stdout.trim() === '') return undefined
173  const added = await git($, ['git', '-C', dir, 'add', '-A'])
174  if (added.exitCode !== 0) return undefined
175  let done = await git($, ['git', '-C', dir, 'commit', '-q', '-m', message])
176  // A repo without a git identity still gets the commit, under a stand-in name.
177  if (done.exitCode !== 0) {
178    done = await git($, ['git', '-C', dir, '-c', 'user.name=OpenCode worker', '-c', 'user.email=opencode-worker@localhost', 'commit', '-q', '-m', message])
179  }
180  if (done.exitCode !== 0) return undefined
181  const stat = await git($, ['git', '-C', dir, 'show', '--stat', '--format=', 'HEAD'])
182  return stat.exitCode === 0 ? stat.stdout.trim() : undefined
183}
184
185export async function runBatch(
186  $: EngineInterface,
187  spec: BatchRunSpec,
188  runTask: (task: BatchTask) => Promise<TaskResult>,
189  onChange: (batch: Batch) => void | Promise<void>,
190): Promise<void> {
191  const batch = spec.batch
192  const max = Math.max(1, Math.min(6, spec.maxParallel))
193  const signal = spec.signal
194  const active = new Set<Promise<void>>()
195  const done = new Set<string>()
196  const failed = new Set<string>()
197  const skipped = new Set<string>()
198
199  const cancelled = (): boolean => signal?.aborted === true
200  const spent = (): number => batch.tasks.reduce((sum, t) => sum + t.cost, 0)
201  const overBudget = (): boolean => batch.maxCost !== undefined && spent() >= batch.maxCost
202
203  while (true) {
204    let changed = false
205    if (cancelled()) {
206      for (const task of batch.tasks) {
207        if (task.status !== 'pending') continue
208        task.status = 'cancelled'
209        task.error = 'batch cancelled'
210        changed = true
211      }
212      if (changed) await onChange(batch)
213    } else {
214      let again = true
215      while (again) {
216        again = false
217        for (const task of batch.tasks) {
218          if (task.status !== 'pending') continue
219          const bad = task.dependsOn.find(id => failed.has(id) || skipped.has(id))
220          if (bad !== undefined) {
221            task.status = 'skipped'
222            task.error = `dependency ${bad} failed`
223            skipped.add(task.id)
224            again = true
225          }
226        }
227        if (again) await onChange(batch)
228      }
229    }
230
231    if (!cancelled() && overBudget()) {
232      const budget = batch.maxCost ?? 0
233      let stopped = false
234      for (const task of batch.tasks) {
235        if (task.status !== 'pending') continue
236        task.status = 'skipped'
237        task.error = `batch budget $${budget.toFixed(2)} reached`
238        skipped.add(task.id)
239        stopped = true
240      }
241      if (stopped) await onChange(batch)
242    }
243
244    while (!cancelled() && !overBudget() && active.size < max) {
245      const next = batch.tasks.find(t => t.status === 'pending' && t.dependsOn.every(id => done.has(id)))
246      if (next === undefined) break
247      next.status = 'running'
248      await onChange(batch)
249      const running: Promise<void> = (async () => {
250        let result: TaskResult
251        try {
252          result = await runTask(next)
253        } catch (err) {
254          result = { status: 'failed', cost: 0, steps: next.steps, error: err instanceof Error ? err.message : String(err) }
255        }
256        next.cost = result.cost
257        next.steps = result.steps
258        next.status = result.status
259        if (result.last !== undefined) next.last = result.last
260        if (result.sessionId !== undefined) next.sessionId = result.sessionId
261        if (result.dir !== undefined) next.dir = result.dir
262        if (result.branch !== undefined) next.branch = result.branch
263        if (result.error !== undefined) next.error = result.error
264        if (result.attempts !== undefined) next.attempts = result.attempts
265        if (result.verify !== undefined) next.verify = result.verify.passed ? 'passed' : 'failed'
266        if (result.status === 'done') done.add(next.id)
267        else failed.add(next.id)
268        await onChange(batch)
269      })()
270      active.add(running)
271      void running.then(
272        () => active.delete(running),
273        () => active.delete(running),
274      )
275    }
276
277    if (active.size === 0) {
278      const pending = batch.tasks.filter(t => t.status === 'pending')
279      if (pending.length > 0) {
280        for (const task of pending) {
281          task.status = cancelled() ? 'cancelled' : 'skipped'
282          task.error = cancelled() ? 'batch cancelled' : 'dependency cycle'
283          skipped.add(task.id)
284        }
285        await onChange(batch)
286      }
287      break
288    }
289    await Promise.race(active)
290  }
291}
292
hooks/catalog.ts 168 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Catalog, WorkerModel } from '../types'
4import { loadAgyModels } from './gemini'
5
6// A Z.AI Coding Plan model lists $0 but draws on the plan's prompt quota, so it is neither free nor paid.
7export const isPlan = (m: WorkerModel) => m.source === 'zai'
8// A Google AI Pro model, run through Antigravity: no per-token cost, drawn from that plan's quota.
9export const isGemini = (m: WorkerModel) => m.source === 'gemini'
10export const isFree = (m: WorkerModel) => !isPlan(m) && !isGemini(m) && m.input === 0 && m.output === 0
11/** Whether a run on the model spends dollars, and so counts against the daily budget. */
12export const costsMoney = (m: WorkerModel) => !isFree(m) && !isPlan(m) && !isGemini(m)
13export const price = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, '') : n}`
14export const fullId = (id: string) => (id.includes('/') ? id : `opencode-go/${id}`)
15
16const MAKERS: [RegExp, string][] = [
17  [/^kimi/, 'Moonshot AI'],
18  [/^glm/, 'Z.ai'],
19  [/^deepseek/, 'DeepSeek'],
20  [/^qwen/, 'Qwen'],
21  [/^minimax/, 'MiniMax'],
22  [/^mimo/, 'Xiaomi'],
23  [/^grok/, 'xAI'],
24  [/^gpt/, 'OpenAI'],
25  [/^claude/, 'Anthropic'],
26  [/^gemini/, 'Google'],
27  [/^muse/, 'Meta'],
28  [/^hy\d/, 'Tencent'],
29  [/^longcat/, 'Meituan'],
30  [/^step/, 'StepFun'],
31  [/^nemotron/, 'NVIDIA'],
32  [/^mistral/, 'Mistral'],
33]
34
35export const makerOf = (id: string) => {
36  const bare = id.split('/')[1] ?? id
37  return MAKERS.find(([re]) => re.test(bare))?.[1] ?? 'Other'
38}
39
40// Hex reads on light and dark themes alike; 'subtle' is a theme key, for makers with no colour.
41export const MAKER_COLORS: Record<string, string> = {
42  DeepSeek: '#4D7CFE',
43  'Z.ai': '#A06CF0',
44  'Moonshot AI': '#1FB5A8',
45  Qwen: '#F08A24',
46  MiniMax: '#E8559A',
47  Xiaomi: '#E0A800',
48  NVIDIA: '#76B900',
49  OpenAI: '#10A37F',
50  Anthropic: '#D97757',
51  Google: '#4285F4',
52  Meta: '#0866FF',
53  Other: 'subtle',
54}
55
56export const colorOf = (id: string) => MAKER_COLORS[makerOf(id)] ?? 'subtle'
57
58export async function findOpencode($: EngineInterface): Promise<string | undefined> {
59  const which = await $.process.run(['sh', '-c', 'command -v opencode'])
60  if (which.exitCode === 0 && which.stdout.trim() !== '') return which.stdout.trim()
61  const home = await $.env.get('HOME')
62  if (home === undefined) return undefined
63  for (const dir of ['.opencode/bin', '.local/bin', '.bun/bin', '.npm-global/bin']) {
64    const path = `${home}/${dir}/opencode`
65    const stat = await $.fs.stat(path).catch(() => undefined)
66    if (stat !== undefined) return path
67  }
68  return undefined
69}
70
71// The providers opencode serves the Z.AI (GLM) Coding Plan through: z.ai, and bigmodel.cn in China.
72export const PLAN_PROVIDERS = ['zai-coding-plan', 'zhipuai-coding-plan']
73
74export const isPlanId = (id: string) => PLAN_PROVIDERS.includes(id.split('/')[0] ?? '')
75
76const sourceOf = (id: string): WorkerModel['source'] => (id.startsWith('opencode-go/') ? 'go' : isPlanId(id) ? 'zai' : 'zen')
77
78// `opencode models --verbose` prints each model as a `provider/id` line followed by its JSON.
79export function parseModels(out: string): WorkerModel[] {
80  const models: WorkerModel[] = []
81  for (const block of out.split(/^(?=(?:opencode|opencode-go|zai-coding-plan|zhipuai-coding-plan)\/\S+$)/m)) {
82    const nl = block.indexOf('\n')
83    if (nl < 0) continue
84    const id = block.slice(0, nl).trim()
85    let m: any
86    try {
87      m = JSON.parse(block.slice(nl + 1))
88    } catch {
89      continue
90    }
91    if (m.status === 'deprecated') continue
92    models.push({
93      id,
94      name: m.name ?? id,
95      source: sourceOf(id),
96      maker: makerOf(id),
97      input: Number(m.cost?.input ?? 0),
98      output: Number(m.cost?.output ?? 0),
99      efforts: Object.keys(m.variants ?? {}),
100      maxOutput: Number(m.limit?.output ?? 32_000),
101      context: Number(m.limit?.context ?? 128_000),
102    })
103  }
104  return models
105}
106
107// What the panel lists: every free model (Go's copy first), Go's paid ones, the Z.AI Coding
108// Plan's, and the Google AI Pro plan's through Antigravity. Zen's paid models bill pay-as-you-go credit, not the Go plan, so they are left out.
109export function worker(models: WorkerModel[]) {
110  const goFree = new Set(models.filter(m => m.source === 'go' && isFree(m)).map(m => m.id.split('/')[1]))
111  const free = models
112    .filter(m => isFree(m) && (m.source === 'go' || !goFree.has(m.id.split('/')[1])))
113    .sort((a, b) => a.name.localeCompare(b.name))
114  const paid = models
115    .filter(m => m.source === 'go' && !isFree(m))
116    .sort((a, b) => a.input + a.output - (b.input + b.output))
117  const plan = models.filter(isPlan).sort((a, b) => a.name.localeCompare(b.name))
118  const gemini = models.filter(isGemini)
119  return { free, paid, plan, gemini }
120}
121
122const CACHE = 'catalog'
123
124const isModel = (m: unknown): m is WorkerModel => {
125  if (m === null || typeof m !== 'object') return false
126  const x = m as WorkerModel
127  return (
128    typeof x.id === 'string' &&
129    typeof x.name === 'string' &&
130    (x.source === 'go' || x.source === 'zen' || x.source === 'zai' || x.source === 'gemini') &&
131    typeof x.maker === 'string' &&
132    typeof x.input === 'number' &&
133    typeof x.output === 'number' &&
134    Array.isArray(x.efforts) &&
135    typeof x.maxOutput === 'number'
136  )
137}
138
139// `opencode models --verbose` takes seconds; the last list the panel saw is good enough to draw at
140// once, and the fresh one replaces it when it arrives.
141export async function readCache($: EngineInterface): Promise<WorkerModel[] | undefined> {
142  const held = (await $.store.get(CACHE).catch(() => undefined)) as { models?: unknown } | undefined
143  if (held === null || typeof held !== 'object' || !Array.isArray(held.models)) return undefined
144  // Caches written before models carried their context window get the usual default.
145  const models = held.models.filter(isModel).map(m => ({ ...m, context: typeof m.context === 'number' ? m.context : 128_000 }))
146  return models.length > 0 ? models : undefined
147}
148
149export async function writeCache($: EngineInterface, models: WorkerModel[]) {
150  await $.store.set(CACHE, { at: Date.now(), models }).catch(() => undefined)
151}
152
153export async function loadCatalog($: EngineInterface): Promise<Catalog> {
154  const [opencode, gemini] = await Promise.all([loadOpencodeModels($), loadAgyModels($).catch(() => [])])
155  if (opencode.models.length === 0 && gemini.length === 0) return { status: 'error', models: [], error: opencode.error ?? 'no models listed' }
156  return { status: 'ready', models: [...opencode.models, ...gemini] }
157}
158
159async function loadOpencodeModels($: EngineInterface): Promise<{ models: WorkerModel[]; error?: string }> {
160  const bin = await findOpencode($)
161  if (bin === undefined) return { models: [], error: 'opencode CLI not found' }
162  const out = await $.process
163    .run([bin, 'models', '--verbose'], { timeoutMs: 60_000 })
164    .catch(err => ({ exitCode: 1, stdout: '', stderr: String(err) }))
165  const models = parseModels(out.stdout.replace(/\x1b\[[0-9;]*m/g, ''))
166  return models.length > 0 ? { models } : { models: [], error: out.stderr.trim().split('\n').at(-1) || 'no models listed' }
167}
168
hooks/gemini.ts 192 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Effort, WorkerModel } from '../types'
4
5// Gemini (and the other models a Google AI Pro plan offers) run through the Antigravity CLI,
6// `agy`, signed in with the user's Google account: no API key and no per-token cost, drawn
7// from the plan's quota. The mod names them `agy/<model>`, with the level split off the id.
8export const AGY_PREFIX = 'agy/'
9export const isAgyId = (id: string) => id.startsWith(AGY_PREFIX)
10
11// agy prints nothing until it answers, so the usual no-output watchdog would kill a working
12// run. agy's own --print-timeout ends it instead, and the watchdog waits a little longer.
13export const AGY_TIMEOUT = '30m'
14export const AGY_STALL_MS = 31 * 60_000
15
16const LEVELS = ['low', 'medium', 'high'] as const
17
18// catalog.ts imports this module, so the makers agy offers are named here.
19const makerOf = (bare: string) =>
20  bare.startsWith('gemini') ? 'Google' : bare.startsWith('claude') ? 'Anthropic' : bare.startsWith('gpt') ? 'OpenAI' : 'Other'
21
22export async function findAgy($: EngineInterface): Promise<string | undefined> {
23  const which = await $.process.run(['sh', '-c', 'command -v agy']).catch(() => undefined)
24  if (which !== undefined && which.exitCode === 0 && which.stdout.trim() !== '') return which.stdout.trim()
25  const home = await $.env.get('HOME')
26  if (home === undefined) return undefined
27  const path = `${home}/.local/bin/agy`
28  return (await $.fs.stat(path).catch(() => undefined)) === undefined ? undefined : path
29}
30
31// `agy models` prints `<id>\t<name>` per model and level, e.g. `gemini-4-argon-high\tGemini 4 Argon (High)`.
32export function parseAgyModels(out: string): WorkerModel[] {
33  const byId = new Map<string, WorkerModel>()
34  for (const line of out.split('\n')) {
35    const [rawId, rawName] = line.split('\t').map(part => part?.trim() ?? '')
36    if (rawId === undefined || rawId === '' || rawName === undefined || rawName === '') continue
37    const level = LEVELS.find(l => rawId.endsWith(`-${l}`))
38    const bare = level === undefined ? rawId : rawId.slice(0, -level.length - 1)
39    const id = `${AGY_PREFIX}${bare}`
40    const name = rawName.replace(/\s*\((Low|Medium|High)\)$/i, '')
41    const known = byId.get(id)
42    if (known !== undefined) {
43      if (level !== undefined && !known.efforts.includes(level)) known.efforts.push(level)
44      continue
45    }
46    byId.set(id, {
47      id,
48      name,
49      source: 'gemini',
50      maker: makerOf(bare),
51      input: 0,
52      output: 0,
53      efforts: level === undefined ? [] : [level],
54      maxOutput: 64_000,
55      context: bare.startsWith('gemini') ? 1_000_000 : 200_000,
56    })
57  }
58  for (const m of byId.values()) m.efforts.sort((a, b) => LEVELS.indexOf(a as never) - LEVELS.indexOf(b as never))
59  return [...byId.values()]
60}
61
62export async function loadAgyModels($: EngineInterface): Promise<WorkerModel[]> {
63  const bin = await findAgy($)
64  if (bin === undefined) return []
65  const out = await $.process.run([bin, 'models'], { timeoutMs: 60_000 }).catch(() => undefined)
66  return out === undefined || out.exitCode !== 0 ? [] : parseAgyModels(out.stdout)
67}
68
69/** The `--model` value: the bare id with a level agy offers (xhigh and max ask for high). */
70export function agyModelArg(model: string, level?: string): string {
71  const bare = model.slice(AGY_PREFIX.length)
72  const wanted = level === 'xhigh' || level === 'max' ? 'high' : level
73  return `${bare}-${LEVELS.includes(wanted as never) ? wanted : 'medium'}`
74}
75
76// Headless agy denies every tool that would ask for permission, so runs skip the prompts.
77// --sandbox keeps credential folders out of the agent's commands; it does not stop writes to
78// the rest of the home folder. readOnly is agy's plan mode, which edits nothing.
79export function agyArgv(
80  bin: string,
81  o: { model: string; level?: string; sessionId?: string; readOnly?: boolean; files?: string[]; prompt: string; logFile: string },
82): string[] {
83  const files = (o.files ?? []).filter(file => file.trim() !== '')
84  const prompt = files.length === 0 ? o.prompt : `${o.prompt}\n\nRead these files first: ${files.join(', ')}`
85  return [
86    bin,
87    `--print=${prompt}`,
88    '--model',
89    agyModelArg(o.model, o.level),
90    '--dangerously-skip-permissions',
91    '--sandbox',
92    '--print-timeout',
93    AGY_TIMEOUT,
94    '--log-file',
95    o.logFile,
96    ...(o.readOnly ? ['--mode', 'plan'] : []),
97    ...(o.sessionId ? ['--conversation', o.sessionId] : []),
98  ]
99}
100
101/** The conversation a run started, from its log: what `--conversation` resumes. */
102export function conversationIn(log: string): string | undefined {
103  return /Created conversation ([0-9a-f-]{36})/.exec(log)?.[1]
104}
105
106/** The model that plans, reviews and checks: Gemini 4 Argon when the plan lists it. */
107export function geminiChecker(models: WorkerModel[]): { model: string; effort: Effort } | undefined {
108  const gemini = models.filter(m => m.source === 'gemini' && m.maker === 'Google')
109  const pick = gemini.find(m => /argon/.test(m.id)) ?? gemini.find(m => !/flash/.test(m.id)) ?? gemini[0]
110  return pick && { model: pick.id, effort: pick.efforts.includes('high') ? 'high' : 'medium' }
111}
112
113/** The checker a call names (a bare id is taken as agy's), else geminiChecker's pick. */
114export function pickChecker(models: WorkerModel[], model?: string, effort?: Effort): { model: string; effort: Effort } | undefined {
115  if (typeof model !== 'string' || model.trim() === '') {
116    const pick = geminiChecker(models)
117    return pick && effort !== undefined ? { ...pick, effort } : pick
118  }
119  const id = model.includes('/') ? model.trim() : `${AGY_PREFIX}${model.trim()}`
120  const known = models.find(m => m.id === id)
121  const fallback: Effort = known === undefined || known.efforts.includes('high') ? 'high' : 'medium'
122  return { model: id, effort: effort ?? fallback }
123}
124
125export const NO_GEMINI =
126  'Gemini runs through the Antigravity CLI signed in to the Google AI Pro plan, and it lists no Gemini models. Ask the user to install it (curl -fsSL https://antigravity.google/cli/install.sh | bash), run `agy` once to sign in, then press ↻ Refresh models in the OpenCode panel.'
127
128export type PlannedTask = { id: string; title: string; prompt: string; dependsOn: string[]; verify?: string }
129export type Plan = { summary: string; tasks: PlannedTask[] }
130
131export const MAX_PLANNED = 6
132
133export function planPrompt(goal: string, o: { shared?: string; verify?: string; maxTasks: number }): string {
134  return [
135    'You are the planner for a team of coding agents that each work alone, in their own copy of this folder, and see nothing but the prompt you write for them.',
136    `Goal:\n${goal}`,
137    ...(o.shared ? [`Context every agent also gets:\n${o.shared}`] : []),
138    'Read the folder first, enough to plan well. Then split the goal into independent tasks that can run in parallel; use dependsOn only where a task truly needs another one\'s result.',
139    `At most ${o.maxTasks} tasks; fewer is better when the goal is small (one task is fine).`,
140    "Each prompt must stand alone: name the files, the change, the constraints and how to know it is done. Keep tasks from editing the same files where you can.",
141    o.verify
142      ? `Every task's verify is \`${o.verify}\` unless a narrower command checks that task better.`
143      : 'Give each coding task a verify shell command that exits 0 only when the task is done (a test, a build, a script).',
144    'Do not create or edit any file. Reply with ONLY a JSON object, no prose and no code fence:',
145    '{"summary": string, "tasks": [{"id": string, "title": string, "prompt": string, "dependsOn": string[], "verify": string}]}',
146    'ids are short kebab-case words.',
147  ].join('\n\n')
148}
149
150// The first {...} in the answer that parses, so a stray fence or sentence around it is harmless.
151export function parsePlan(text: string, maxTasks = MAX_PLANNED): Plan | string {
152  const start = text.indexOf('{')
153  const end = text.lastIndexOf('}')
154  if (start < 0 || end <= start) return 'The planner gave no JSON plan.'
155  let raw: any
156  try {
157    raw = JSON.parse(text.slice(start, end + 1))
158  } catch {
159    return 'The planner\'s JSON plan does not parse.'
160  }
161  const list: unknown[] = Array.isArray(raw?.tasks) ? raw.tasks : []
162  const tasks: PlannedTask[] = []
163  for (const t of list) {
164    const x = t as Record<string, unknown>
165    if (typeof x?.id !== 'string' || typeof x.prompt !== 'string' || x.prompt.trim() === '') continue
166    const id = x.id.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || `t${tasks.length + 1}`
167    if (tasks.some(p => p.id === id)) continue
168    tasks.push({
169      id,
170      title: typeof x.title === 'string' && x.title.trim() !== '' ? x.title.trim() : id,
171      prompt: x.prompt,
172      dependsOn: Array.isArray(x.dependsOn) ? x.dependsOn.filter((d): d is string => typeof d === 'string') : [],
173      ...(typeof x.verify === 'string' && x.verify.trim() !== '' ? { verify: x.verify.trim() } : {}),
174    })
175  }
176  if (tasks.length === 0) return 'The planner\'s plan has no tasks.'
177  if (tasks.length > maxTasks) return `The planner made ${tasks.length} tasks; at most ${maxTasks} are allowed.`
178  const ids = new Set(tasks.map(t => t.id))
179  for (const t of tasks) t.dependsOn = t.dependsOn.filter(d => ids.has(d) && d !== t.id)
180  return { summary: typeof raw.summary === 'string' ? raw.summary.trim() : '', tasks }
181}
182
183export function checkPrompt(goal: string, diff: string): string {
184  return [
185    `Coding agents worked on this goal: ${goal.slice(0, 6_000)}`,
186    `Their merged changes:\n${diff}`,
187    'Check the merged result as a whole: does it meet the goal, do the parts fit together, is anything missing or broken? The folder holds the code before these changes; the diff is what changed.',
188    'Reply with a first line of exactly `VERDICT: OK` or `VERDICT: CONCERNS`, then at most 8 short bullets `- <file>:<line> <problem>` (none needed for OK).',
189    'Be strict about real problems and silent about style.',
190  ].join('\n\n')
191}
192
hooks/commands.ts 147 lines
1import type { Batch, BatchSummary, Effort, Job, LocalServer, Preset, WorkerModel } from '../types'
2import { isFree, isPlan, price } from './catalog'
3import type { LocalSpec } from './local'
4import { ageOf, ICONS, shortModel } from './panel'
5
6// The `/opencode` subcommands' parsing and text. register.tsx reads and changes the state around these.
7
8export const HELP = [
9  'OpenCode commands:',
10  '  /opencode                      open the panel',
11  '  /opencode model <name>         pick the worker model',
12  '  /opencode effort <level>       set reasoning: low, medium, high, xhigh, max',
13  '  /opencode chat on|off          send this chat through OpenCode Go',
14  '  /opencode models [free|paid|zai|gemini] list models',
15  '  /opencode preset [name]        apply or list model presets',
16  '  /opencode batches              list this chat\'s batches',
17  '  /opencode cancel <id>|all      stop a job, batch or every run',
18  '  /opencode diff <batch> <task>  show a task branch\'s diff',
19  '  /opencode merge <batch> <task> merge a task branch',
20  '  /opencode discard <batch> <task> remove a task\'s worktree and branch',
21  '  /opencode resume <batch> <task> continue a failed or cancelled task',
22  '  /opencode history              recent batches and spend by model',
23  '  /opencode status               what is running and what it cost',
24  '  /opencode local [start|stop|setup] [kev|laya]  local decision models',
25  '  /opencode selftest             check opencode, model sources, local models, hangs, rate limits and notifications',
26  '  /opencode help                 this list',
27].join('\n')
28
29export function parseArgs(args: string) {
30  const trimmed = args.trim()
31  const gap = trimmed.indexOf(' ')
32  const sub = (gap < 0 ? trimmed : trimmed.slice(0, gap)).toLowerCase()
33  const rest = gap < 0 ? '' : trimmed.slice(gap + 1).trim()
34  return { sub, rest }
35}
36
37// One model by exact id, bare id or a unique part of its id or name; otherwise the text to show.
38export function matchModel(list: WorkerModel[], rest: string, full: (id: string) => string): { model: WorkerModel } | { text: string } {
39  const q = rest.toLowerCase()
40  const exact = list.find(m => m.id === rest) ?? list.find(m => m.id === full(rest))
41  const found = exact !== undefined ? [exact] : list.filter(m => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q))
42  if (found.length > 1) {
43    const ids = found.slice(0, 8).map(m => m.id)
44    return { text: `Several match: ${ids.join(', ')}${found.length > 8 ? ' …' : ''}` }
45  }
46  const only = found[0]
47  if (only === undefined) return { text: `No model matches "${rest}". Try /opencode models.` }
48  return { model: only }
49}
50
51export function effortText(level: Effort, known: WorkerModel | undefined) {
52  const note =
53    known !== undefined && !known.efforts.includes(level)
54      ? ` (${known.name} takes ${known.efforts.join(', ') || 'no reasoning levels'}; it runs at its default)`
55      : ''
56  return `Reasoning: ${level}${note}`
57}
58
59export function modelsText(free: WorkerModel[], paid: WorkerModel[], plan: WorkerModel[], rest: string, gemini: WorkerModel[] = []) {
60  const line = (m: WorkerModel) => `${m.id} · ${isPlan(m) ? 'Z.AI plan' : m.source === 'gemini' ? `Google AI Pro · ${m.efforts.join('/')}` : isFree(m) ? 'free' : `${price(m.input)}/${price(m.output)}`}`
61  const sections: [string, WorkerModel[]][] = []
62  if (rest === '' || rest === 'free') sections.push(['FREE', free])
63  if (rest === '' || rest === 'paid') sections.push(['PAID', paid])
64  if (rest === '' || rest === 'zai') sections.push(['Z.AI PLAN', plan])
65  if (rest === '' || rest === 'gemini') sections.push(['GEMINI (Google AI Pro via Antigravity)', gemini])
66  return sections.map(([title, list]) => `${title}\n${list.length ? list.map(line).join('\n') : '(none)'}`).join('\n\n')
67}
68
69// Installing Kev and Laya takes minutes and gigabytes, so the mod gives the command rather than running it.
70export function setupText(root: string, dir: string, only?: string) {
71  const args = [`--dir ${dir}`, ...(only === 'kev' || only === 'laya' ? [`--only ${only}`] : [])].join(' ')
72  return [
73    `To install ${only === 'kev' ? 'Kev' : only === 'laya' ? 'Laya' : 'Kev and Laya'} into ${dir}, run in a terminal (needs git and uv):`,
74    '',
75    `  ${root}/scripts/setup-local.sh ${args}`,
76    '',
77    'Add --dry-run to see the steps first. Then start them from the panel or with /opencode local start.',
78  ].join('\n')
79}
80
81export function localText(specs: LocalSpec[], all: Record<string, LocalServer>) {
82  return specs.map(x => `${x.label} :${x.port} · ${all[x.name]?.status ?? 'off'}${all[x.name]?.error ? ` · ${all[x.name]?.error}` : ''}`).join('\n')
83}
84
85export function statusText(o: { model: string; level: Effort; isChat: boolean; running: Job[]; batches: Batch[]; session: number; runs: number; total: number }) {
86  const live = o.batches.filter(b => b.status === 'running')
87  return [
88    `Worker: ${o.model} · ${o.level}`,
89    `Chat mode: ${o.isChat ? 'on' : 'off'}`,
90    `Running delegate jobs: ${o.running.length}`,
91    live.length
92      ? `Batches: ${live.length} running\n${live.map(b => `  ${b.title} · ${b.tasks.filter(t => t.status === 'done').length}/${b.tasks.length}`).join('\n')}`
93      : 'Batches: none running',
94    `Spend: $${o.session.toFixed(4)} this chat over ${o.runs} runs · $${o.total.toFixed(2)} all time`,
95  ].join('\n')
96}
97
98export function presetsText(prefix: string, model: string, level: Effort, presets: Preset[]) {
99  const rows = presets.map(
100    (p: Preset) => `${p.name}${p.model === model && p.effort === level ? ' (current)' : ''} · ${p.model} · ${p.effort} · ${p.note}`,
101  )
102  return [prefix, ...rows].filter(line => line !== '').join('\n')
103}
104
105export function batchesText(list: Batch[]) {
106  if (list.length === 0) return 'No batches in this chat.'
107  const lines: string[] = []
108  for (const b of [...list].sort((a, c) => c.startedAt - a.startedAt)) {
109    const done = b.tasks.filter(t => t.status === 'done').length
110    const cost = b.tasks.reduce((n, t) => n + t.cost, 0)
111    lines.push(`${b.id} · ${b.title} · ${b.status} · ${done}/${b.tasks.length} · $${cost.toFixed(4)}`)
112    for (const t of b.tasks) lines.push(`  ${ICONS[t.status]} ${t.id} ${t.status}`)
113  }
114  return lines.join('\n')
115}
116
117// A job by exact id or unique prefix; otherwise the text to show.
118export function matchJob(list: Job[], rest: string): { job: Job } | { text: string } {
119  const exact = list.find(j => j.id === rest)
120  const matches = exact !== undefined ? [exact] : list.filter(j => j.id.startsWith(rest))
121  if (matches.length > 1) return { text: `Several jobs match: ${matches.map(j => j.id).join(', ')}` }
122  const only = matches[0]
123  if (only === undefined) return { text: `Nothing matches "${rest}".` }
124  return { job: only }
125}
126
127export function diffText(text: string) {
128  const lines = text.split('\n')
129  const shown = lines.slice(0, 200).join('\n')
130  return lines.length > 200 ? `${shown}\n… ${lines.length - 200} more lines` : shown
131}
132
133export function historyText(history: BatchSummary[], spendByModel: Record<string, number>) {
134  const finished = [...history].slice(-10).reverse()
135  const byModel = Object.entries(spendByModel)
136    .sort((a, b) => b[1] - a[1])
137    .slice(0, 5)
138    .map(([id, usd]) => `${shortModel(id)} $${usd.toFixed(2)}`)
139  const lines = [
140    finished.length === 0
141      ? 'No finished batches yet.'
142      : finished.map(entry => `${entry.title} · ${entry.status} · ${entry.done}/${entry.tasks} · $${entry.cost.toFixed(4)} · ${ageOf(entry.endedAt)}`).join('\n'),
143    byModel.length > 0 ? `Spend by model:\n${byModel.join('\n')}` : '',
144  ]
145  return lines.filter(line => line !== '').join('\n\n')
146}
147
hooks/chat.ts 226 lines
1import type { EngineInterface, SessionMessage } from 'claude-code'
2
3import type { Effort, WorkerModel } from '../types'
4
5// Chat mode answers a turn's model request through OpenCode Go's Anthropic Messages endpoint.
6// OpenCode lists Claude Code as a validated Go client; Zen's free tier serves only OpenCode itself,
7// so chat mode runs on opencode-go/* models alone.
8export const ENDPOINT = 'https://opencode.ai/inference/go/anthropic/v1/messages'
9const USER_AGENT = 'claude-code-opencode-go-mod/0.6'
10
11const THINKING: Record<Effort, number> = { low: 2_048, medium: 8_192, high: 16_384, xhigh: 32_768, max: 64_000 }
12
13const str = (description: string) => ({ type: 'string', description })
14const int = (description: string) => ({ type: 'integer', description })
15const bool = (description: string) => ({ type: 'boolean', description })
16
17// The input schemas of the session's core tools, as Claude Code's own tools take them.
18// The mod sends only those the session actually has.
19const TOOLS: { name: string; description: string; input_schema: Record<string, unknown> }[] = [
20  {
21    name: 'Read',
22    description: 'Read a file from the local filesystem. file_path must be absolute. Returns lines numbered like cat -n.',
23    input_schema: { type: 'object', properties: { file_path: str('Absolute path'), offset: int('First line to read'), limit: int('Number of lines') }, required: ['file_path'] },
24  },
25  {
26    name: 'Write',
27    description: 'Write a file, replacing it if it exists. Read an existing file first.',
28    input_schema: { type: 'object', properties: { file_path: str('Absolute path'), content: str('The whole new content') }, required: ['file_path', 'content'] },
29  },
30  {
31    name: 'Edit',
32    description: 'Replace an exact string in a file. old_string must match exactly and be unique unless replace_all is true. Read the file first.',
33    input_schema: {
34      type: 'object',
35      properties: { file_path: str('Absolute path'), old_string: str('Text to replace'), new_string: str('Replacement'), replace_all: bool('Replace every occurrence') },
36      required: ['file_path', 'old_string', 'new_string'],
37    },
38  },
39  {
40    name: 'Bash',
41    description: 'Run a bash command and return its output.',
42    input_schema: {
43      type: 'object',
44      properties: { command: str('The command'), description: str('What it does, in a few words'), timeout: { type: 'number', description: 'Milliseconds, max 600000' }, run_in_background: bool('Run detached') },
45      required: ['command'],
46    },
47  },
48  {
49    name: 'Glob',
50    description: 'Find files by glob pattern, newest first.',
51    input_schema: { type: 'object', properties: { pattern: str('Glob pattern'), path: str('Directory to search') }, required: ['pattern'] },
52  },
53  {
54    name: 'Grep',
55    description: 'Search file contents with ripgrep.',
56    input_schema: {
57      type: 'object',
58      properties: {
59        pattern: str('Regular expression'),
60        path: str('File or directory'),
61        glob: str('Filter files by glob'),
62        output_mode: { type: 'string', enum: ['content', 'files_with_matches', 'count'] },
63        '-i': bool('Case insensitive'),
64        '-n': bool('Show line numbers'),
65        head_limit: int('Limit results'),
66      },
67      required: ['pattern'],
68    },
69  },
70  {
71    name: 'WebFetch',
72    description: 'Fetch a URL and answer a prompt about its content.',
73    input_schema: { type: 'object', properties: { url: str('The URL'), prompt: str('What to extract') }, required: ['url', 'prompt'] },
74  },
75]
76
77export async function chatTools($: EngineInterface) {
78  const have = new Set((await $.tool.list()).map(t => t.name))
79  return TOOLS.filter(t => have.has(t.name))
80}
81
82type Block = Record<string, unknown>
83type Msg = { role: 'user' | 'assistant'; content: Block[] }
84
85// Rebuilds the conversation as Anthropic messages from the engine's transcript summary:
86// text and tool calls with their results. Images and earlier thinking are not carried.
87export function toMessages(transcript: readonly SessionMessage[]): Msg[] {
88  const out: Msg[] = []
89  const push = (role: Msg['role'], content: Block[]) => {
90    if (content.length === 0) return
91    const last = out.at(-1)
92    if (last?.role === role) last.content.push(...content)
93    else out.push({ role, content })
94  }
95  for (const m of transcript) {
96    if (m.role === 'assistant') {
97      const blocks: Block[] = []
98      if (m.text.trim()) blocks.push({ type: 'text', text: m.text })
99      for (const use of m.toolUses) blocks.push({ type: 'tool_use', id: use.tool_use_id, name: use.tool, input: use.input ?? {} })
100      push('assistant', blocks)
101      // Each call's result goes in the next user message, ahead of any text, as the API requires.
102      const results = m.toolUses.map(use => ({
103        type: 'tool_result',
104        tool_use_id: use.tool_use_id,
105        content: use.text ?? (use.result === undefined ? '(no result)' : JSON.stringify(use.result)),
106        ...(use.isError ? { is_error: true } : {}),
107      }))
108      push('user', results)
109    } else if (m.text.trim()) {
110      push('user', [{ type: 'text', text: m.text }])
111    }
112  }
113  while (out[0]?.role === 'assistant') out.shift()
114  return out
115}
116
117export async function readKey($: EngineInterface): Promise<string | undefined> {
118  const data = (await $.env.get('XDG_DATA_HOME')) ?? `${await $.env.get('HOME')}/.local/share`
119  const text = await $.fs.read(`${data}/opencode/auth.json`).catch(() => undefined)
120  if (typeof text !== 'string') return undefined
121  try {
122    const auth = JSON.parse(text)
123    return auth['opencode-go']?.key ?? auth.opencode?.key
124  } catch {
125    return undefined
126  }
127}
128
129export function requestBody(modelId: string, model: WorkerModel | undefined, level: Effort, system: string, tools: unknown[], messages: Msg[]) {
130  const maxOutput = Math.min(model?.maxOutput ?? 32_000, 128_000)
131  const canThink = model === undefined || model.efforts.length > 0
132  const budget = Math.min(THINKING[level], maxOutput - 4_096)
133  return {
134    model: modelId.split('/')[1] ?? modelId,
135    max_tokens: maxOutput,
136    system,
137    tools,
138    messages,
139    ...(canThink && budget >= 1_024 ? { thinking: { type: 'enabled', budget_tokens: budget } } : {}),
140  }
141}
142
143type Reply = { content: Block[]; stop_reason: string | null; model?: string; usage?: Record<string, number> }
144type SendError = { status: number; message: string; thrown: boolean }
145
146export function explainError(status: number, message: string) {
147  if (status === 401 || status === 403) return 'OpenCode rejected the key. Run `opencode auth login` and choose OpenCode Go.'
148  if (status === 429) return `OpenCode Go is busy or rate limiting (${message}); try again shortly or pick another model. If it keeps happening, your allowance may be used up.`
149  if (/global region/i.test(message)) return `${message} (OpenCode web console → your workspace → Privacy → Global)`
150  if (status === 400 && /tool|thinking/i.test(message))
151    return `${message} (this model may not support tools or thinking; try another model or a lower reasoning level)`
152  return message
153}
154
155// `$.http.fetch` takes no AbortSignal, so the wait around it is the abortable part: an interrupted
156// dispatch stops waiting at once, and whatever the request later answers is dropped.
157export function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T | { aborted: true }> {
158  if (signal.aborted) return Promise.resolve({ aborted: true })
159  return new Promise((resolve, reject) => {
160    const stop = () => resolve({ aborted: true })
161    signal.addEventListener('abort', stop, { once: true })
162    promise.then(
163      value => {
164        signal.removeEventListener('abort', stop)
165        resolve(value)
166      },
167      err => {
168        signal.removeEventListener('abort', stop)
169        reject(err)
170      },
171    )
172  })
173}
174
175export async function send($: EngineInterface, key: string, session: string, body: unknown): Promise<{ response: Reply } | { error: SendError }> {
176  let res: Awaited<ReturnType<EngineInterface['http']['fetch']>>
177  try {
178    res = await $.http.fetch(ENDPOINT, {
179      method: 'POST',
180      headers: {
181        authorization: `Bearer ${key}`,
182        'x-opencode-session': session,
183        'user-agent': USER_AGENT,
184        'anthropic-version': '2023-06-01',
185        'content-type': 'application/json',
186      },
187      body: JSON.stringify(body),
188    })
189  } catch (err) {
190    return { error: { status: 0, message: err instanceof Error ? err.message : String(err), thrown: true } }
191  }
192  let json: any
193  try {
194    json = JSON.parse(res.text)
195  } catch {
196    json = undefined
197  }
198  if (!res.ok || json?.type === 'error') {
199    const message = String(json?.error?.message ?? json?.error?.type ?? res.text.slice(0, 300)).trim()
200    return { error: { status: res.status, message: message || 'request failed', thrown: false } }
201  }
202  return { response: json as Reply }
203}
204
205const RETRY = new Set([429, 500, 502, 503, 504, 529])
206
207export async function sendWithRetry(
208  $: EngineInterface,
209  key: string,
210  session: string,
211  body: ReturnType<typeof requestBody>,
212  signal: AbortSignal,
213) {
214  const sent = await abortable(send($, key, session, body), signal)
215  if ('aborted' in sent) return sent
216  const retry = 'error' in sent && (sent.error.thrown || RETRY.has(sent.error.status))
217  if (!retry) return sent
218  $.ui.status(`OpenCode chat: ${body.model} retrying…`)
219  try {
220    await $.clock.sleep(2_000, { signal })
221  } catch {
222    return { aborted: true as const }
223  }
224  return abortable(send($, key, session, body), signal)
225}
226
hooks/control.ts 25 lines
1// Running opencode work by id, so the panel and /opencode cancel can stop it.
2// A delegate job is tracked under its job id, a batch under `batch:<id>`.
3const controllers = new Map<string, AbortController>()
4
5export function track(id: string): AbortSignal {
6  const c = new AbortController()
7  controllers.set(id, c)
8  return c.signal
9}
10
11export function untrack(id: string) {
12  controllers.delete(id)
13}
14
15export function cancel(id: string): boolean {
16  const c = controllers.get(id)
17  if (c === undefined) return false
18  c.abort()
19  return true
20}
21
22export function tracked(): string[] {
23  return [...controllers.keys()]
24}
25
hooks/delegate.ts 542 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Phase, WorkerModel } from '../types'
4import { AGY_STALL_MS, agyArgv, conversationIn, findAgy, isAgyId } from './gemini'
5
6export type Run = {
7  text: string
8  sessionId?: string
9  tools: string[]
10  errors: string[]
11  cost: number
12  /** model calls (step_finish events) */
13  steps?: number
14  /** model calls per model id, across a resilient run's attempts */
15  calls?: Record<string, number>
16}
17
18export type RunEnd = { code: number | null; signal: string | null }
19
20export type EndReason = 'ok' | 'failed' | 'rate-limited' | 'hung' | 'cancelled'
21
22export type VerifyResult = { passed: boolean; output: string; rounds?: number }
23
24// reason labels why this attempt runs; previous is how the last attempt actually ended.
25export type AttemptInfo = { attempt: number; model: string; reason: string; previous: EndReason }
26
27export type ResilientOptions = {
28  bin: string
29  model: string
30  fallback?: string
31  // When set, used instead of fallback: each rung in turn, with its own retries.
32  fallbacks?: { model: string; level?: string; retries?: number }[]
33  level?: string
34  prompt: string
35  cwd: string | undefined
36  autoApprove?: boolean
37  readOnly?: boolean
38  files?: string[]
39  sessionId?: string
40  retries: number
41  firstRetries?: number
42  stallMs: number
43  signal?: AbortSignal
44  verify?: string
45  verifyRounds: number
46  onAttempt?: (info: AttemptInfo) => void | Promise<void>
47  // Called with 'verifying' when the verify loop starts, and with undefined when it ends.
48  onStage?: (stage: Phase | undefined) => void | Promise<void>
49}
50
51export type ResilientResult = {
52  run: Run
53  end: RunEnd
54  stderr: string
55  sessionId?: string
56  model: string
57  attempts: number
58  reason: EndReason
59  verify?: VerifyResult
60  ms: number
61}
62
63// How long a resume waits before it starts, times the retry number. Abortable through the run's signal.
64export const RETRY_WAIT_MS = 15_000
65
66const RATE_LIMIT = [/429/, /rate limit/i, /endpoint is unavailable/i, /overloaded/i, /RESOURCE_EXHAUSTED/, /quota (exceeded|exhausted)/i]
67
68// `--` stops a prompt that starts with `-` being read as a flag. The working folder is the
69// spawn's cwd alone: passing --dir as well resolved a relative dir twice.
70export function buildArgv(
71  bin: string,
72  o: { model: string; level?: string; sessionId?: string; autoApprove?: boolean; readOnly?: boolean; files?: string[]; prompt: string },
73): string[] {
74  return [
75    bin,
76    'run',
77    '--format',
78    'json',
79    '-m',
80    o.model,
81    ...(o.level ? ['--variant', o.level] : []),
82    ...(o.sessionId ? ['--session', o.sessionId] : []),
83    ...(o.autoApprove ? ['--auto'] : []),
84    ...(o.readOnly ? ['--agent', 'plan'] : []),
85    ...(o.files ?? []).flatMap(file => ['-f', file]),
86    '--',
87    o.prompt,
88  ]
89}
90
91// Pure string code: no Node path module is available to a mod.
92export function resolveDir(base: string, dir?: string): string | undefined {
93  if (dir === undefined) return undefined
94  const joined = dir.startsWith('/') ? dir : base === '' ? dir : `${base}/${dir}`
95  const parts: string[] = []
96  for (const part of joined.split('/')) {
97    if (part === '' || part === '.') continue
98    if (part === '..') {
99      if (parts.length > 0 && parts.at(-1) !== '..') parts.pop()
100      else if (!joined.startsWith('/')) parts.push('..')
101    } else parts.push(part)
102  }
103  const out = parts.join('/')
104  if (joined.startsWith('/')) return `/${out}`
105  return out === '' ? '.' : out
106}
107
108// Folds one line of `opencode run --format json` into the run so far.
109export function readEvent(run: Run, line: string) {
110  let ev: any
111  try {
112    ev = JSON.parse(line)
113  } catch {
114    if (line.trim() !== '') run.errors.push(line.trim())
115    return
116  }
117  run.sessionId ??= ev.sessionID ?? ev.part?.sessionID
118  const part = ev.part ?? {}
119  if (ev.type === 'text' || part.type === 'text') {
120    if (typeof part.text === 'string') run.text += (run.text === '' ? '' : '\n') + part.text
121  } else if (ev.type === 'tool_use' || part.type === 'tool') {
122    const title = part.state?.title ?? part.state?.input?.filePath ?? part.state?.input?.command ?? ''
123    const shown = String(title).length > 100 ? `…${String(title).slice(-99)}` : String(title)
124    run.tools.push(`${part.tool ?? 'tool'}${shown ? `: ${shown}` : ''}`)
125  } else if (ev.type === 'step_finish' || part.type === 'step-finish') {
126    run.steps = (run.steps ?? 0) + 1
127    if (typeof part.cost === 'number') run.cost += part.cost
128  } else if (ev.type === 'error') {
129    run.errors.push(ev.error?.data?.message ?? ev.error?.message ?? JSON.stringify(ev.error ?? ev))
130  }
131}
132
133// A watchdog is a real host timer: the stall limit is wall time, and a test kit without a
134// clock still has setTimeout.
135type Timers = {
136  setTimeout?: (fn: () => void, ms: number) => unknown
137  clearTimeout?: (handle: unknown) => void
138}
139
140function timers(): Timers {
141  return globalThis as unknown as Timers
142}
143
144
145function looksRateLimited(run: Run, stderr: string): boolean {
146  const text = [...run.errors, stderr].join('\n')
147  return RATE_LIMIT.some(re => re.test(text))
148}
149
150export async function runOpencode(
151  $: EngineInterface,
152  argv: readonly string[],
153  cwd: string | undefined,
154  onProgress: (run: Run) => void | Promise<void>,
155  // text: the whole of stdout is the answer (agy), not `opencode run --format json` events.
156  o: { stallMs?: number; signal?: AbortSignal; format?: 'json' | 'text' } = {},
157): Promise<{ run: Run; end: RunEnd; stderr: string; reason: EndReason }> {
158  const run: Run = { text: '', tools: [], errors: [], cost: 0 }
159  const asText = o.format === 'text'
160  let pending = ''
161  let stderr = ''
162  let end: RunEnd = { code: null, signal: null }
163  let hung = false
164  let cancelled = o.signal?.aborted === true
165  if (cancelled) {
166    end = { code: null, signal: 'cancelled' }
167    return { run, end, stderr, reason: 'cancelled' }
168  }
169  const host = timers()
170  // opencode takes its project folder from $PWD before its real cwd, and a spawn inherits the
171  // host's $PWD, so without this every worker would start in the folder Claude Code started in.
172  const env = cwd !== undefined && cwd.startsWith('/') ? { PWD: cwd } : undefined
173  const stream = $.process.spawn(env === undefined ? { argv, cwd } : { argv, cwd, env })[Symbol.asyncIterator]()
174  while (!cancelled) {
175    if (o.signal?.aborted === true) {
176      cancelled = true
177      try {
178        void stream.return?.(end)?.catch?.(() => undefined)
179      } catch {
180        // Leaving the loop is what kills the child.
181      }
182      break
183    }
184    const pulled = stream.next()
185    let timer: unknown
186    let stall = false
187    let wake: (() => void) | undefined
188    const woken = new Promise<void>(resolve => (wake = resolve))
189    const onStall = () => {
190      stall = true
191      wake?.()
192    }
193    // No host timer: fall back to the engine clock, ended early once the piece arrives.
194    let sleeper: AbortController | undefined
195    if (o.stallMs !== undefined && o.stallMs > 0) {
196      if (host.setTimeout !== undefined) timer = host.setTimeout(onStall, o.stallMs)
197      else {
198        sleeper = new AbortController()
199        $.clock.sleep(o.stallMs, { signal: sleeper.signal }).then(onStall, () => undefined)
200      }
201    }
202    const onAbort = () => {
203      cancelled = true
204      wake?.()
205    }
206    o.signal?.addEventListener('abort', onAbort)
207    let first: { kind: 'next'; value: IteratorResult<{ stream: 'stdout' | 'stderr'; text: string }, RunEnd> } | { kind: 'wake' }
208    try {
209      first = await Promise.race([
210        pulled.then(value => ({ kind: 'next' as const, value })),
211        woken.then(() => ({ kind: 'wake' as const })),
212      ])
213    } finally {
214      if (timer !== undefined) host.clearTimeout?.(timer)
215      sleeper?.abort()
216      o.signal?.removeEventListener('abort', onAbort)
217    }
218    if (first.kind === 'wake') {
219      void pulled.catch(() => undefined)
220      try {
221        void stream.return?.(end)?.catch?.(() => undefined)
222      } catch {
223        // Leaving the loop is what kills the child; a stream that cannot close is left to it.
224      }
225      if (!cancelled) hung = true
226      break
227    }
228    const step = first.value
229    if (step.done) {
230      end = step.value
231      break
232    }
233    const { stream: pipe, text } = step.value
234    if (pipe === 'stderr') {
235      stderr = (stderr + text).slice(-4000)
236      continue
237    }
238    if (asText) {
239      pending += text
240      continue
241    }
242    const lines = (pending + text).split('\n')
243    pending = lines.pop() ?? ''
244    const before = run.tools.length
245    for (const line of lines) readEvent(run, line)
246    if (run.tools.length !== before) await onProgress(run)
247  }
248  if (asText) {
249    const answer = pending.trim()
250    // agy's own failures arrive on stdout as `jetski: …`.
251    if (/^jetski:/.test(answer)) run.errors.push(answer)
252    else run.text = answer
253  } else if (pending !== '') readEvent(run, pending)
254  if (hung) end = { code: null, signal: 'hung' }
255  if (cancelled) end = { code: null, signal: 'cancelled' }
256
257  // A finished run that merely mentions a rate limit along the way is still ok.
258  const failed = end.code !== 0 || (run.text === '' && (asText || run.errors.length > 0))
259  const reason: EndReason = cancelled
260    ? 'cancelled'
261    : hung
262      ? 'hung'
263      : !failed
264        ? 'ok'
265        : looksRateLimited(run, stderr)
266          ? 'rate-limited'
267          : 'failed'
268  return { run, end, stderr, reason }
269}
270
271// One agy attempt: its log names the conversation a resume continues, and is removed after.
272async function runAgy(
273  $: EngineInterface,
274  o: Pick<ResilientOptions, 'model' | 'level' | 'sessionId' | 'readOnly' | 'files' | 'prompt' | 'cwd' | 'stallMs' | 'signal'>,
275  onProgress: (run: Run) => void | Promise<void>,
276): Promise<{ run: Run; end: RunEnd; stderr: string; reason: EndReason }> {
277  const bin = await findAgy($)
278  if (bin === undefined) {
279    const run: Run = { text: '', tools: [], errors: ['The Antigravity CLI (agy) is not installed or not on PATH.'], cost: 0 }
280    return { run, end: { code: 127, signal: null }, stderr: '', reason: 'failed' }
281  }
282  const home = (await $.env.get('HOME')) ?? '/tmp'
283  const folder = `${home}/.cache/opencode-go`
284  const logFile = `${folder}/agy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.log`
285  await $.process.run(['mkdir', '-p', folder]).catch(() => undefined)
286  const argv = agyArgv(bin, { model: o.model, level: o.level, sessionId: o.sessionId, readOnly: o.readOnly, files: o.files, prompt: o.prompt, logFile })
287  try {
288    const result = await runOpencode($, argv, o.cwd, onProgress, { stallMs: Math.max(o.stallMs, AGY_STALL_MS), signal: o.signal, format: 'text' })
289    const log = await $.fs.read(logFile).catch(() => '')
290    result.run.sessionId = conversationIn(typeof log === 'string' ? log : '') ?? o.sessionId
291    // agy reports no steps: each run counts as one call on the plan.
292    result.run.steps = 1
293    return result
294  } finally {
295    await $.process.run(['rm', '-f', logFile]).catch(() => undefined)
296  }
297}
298
299function merge(into: Run, from: Run) {
300  into.text = from.text
301  if (from.sessionId !== undefined) into.sessionId = from.sessionId
302  into.tools.push(...from.tools)
303  into.errors.push(...from.errors)
304  into.cost += from.cost
305}
306
307export function retryPrompt(reason: string): string {
308  return `Continue the task from where you stopped (the last request failed: ${reason}). Check what you already changed before going on, then finish it.`
309}
310
311export function verifyPrompt(command: string, output: string): string {
312  return `Your work does not pass the check \`${command}\` yet. Its output (last 3000 chars):\n${output.slice(-3000)}\nFix it, re-run the check yourself, and finish.`
313}
314
315async function runVerify($: EngineInterface, command: string, cwd: string | undefined): Promise<{ passed: boolean; output: string }> {
316  const result = await $.process.run(['sh', '-c', command], { cwd, timeoutMs: 600_000 }).catch(err => ({
317    exitCode: 1,
318    stdout: '',
319    stderr: err instanceof Error ? err.message : String(err),
320    isStdoutTruncated: false,
321    isStderrTruncated: false,
322  }))
323  const output = [result.stdout, result.stderr].filter(part => part.trim() !== '').join('\n').trim()
324  return { passed: result.exitCode === 0, output }
325}
326
327// Runs a worker, then resumes it over rate limits, hangs, failures and failed checks, and
328// finally on the fallback model. Merges every attempt's text, tools, errors and cost.
329export async function runResilient(
330  $: EngineInterface,
331  o: ResilientOptions,
332  onProgress: (run: Run) => void | Promise<void>,
333): Promise<ResilientResult> {
334  const started = Date.now()
335  const merged: Run = { text: '', tools: [], errors: [], cost: 0 }
336  let sessionId = o.sessionId
337  let sessionOnAgy = isAgyId(o.model)
338  let model = o.model
339  let level = o.level
340  let attempts = 0
341  // Widened, so checks after the attempts see what they set.
342  let reason = 'failed' as EndReason
343  let verify: VerifyResult | undefined
344  let end: RunEnd = { code: null, signal: null }
345  let stderr = ''
346
347  const attempt = async (prompt: string, nextModel: string, nextSession: string | undefined, why: string, nextLevel: string | undefined) => {
348    if (o.signal?.aborted === true) {
349      reason = 'cancelled'
350      return undefined
351    }
352    if (attempts > 0 && o.onAttempt !== undefined) await o.onAttempt({ attempt: attempts + 1, model: nextModel, reason: why, previous: reason })
353    attempts++
354    // A session belongs to one CLI: moving between opencode and agy starts the task afresh.
355    const viaAgy = isAgyId(nextModel)
356    if (viaAgy !== sessionOnAgy) {
357      if (nextSession !== undefined) prompt = o.prompt
358      nextSession = undefined
359      sessionId = undefined
360    }
361    const result = viaAgy
362      ? await runAgy($, { ...o, model: nextModel, level: nextLevel, sessionId: nextSession, prompt }, onProgress)
363      : await runOpencode(
364          $,
365          buildArgv(o.bin, {
366            model: nextModel,
367            level: nextLevel,
368            sessionId: nextSession,
369            autoApprove: o.autoApprove,
370            readOnly: o.readOnly,
371            files: o.files,
372            prompt,
373          }),
374          o.cwd,
375          onProgress,
376          { stallMs: o.stallMs, signal: o.signal },
377        )
378    sessionOnAgy = viaAgy
379    merge(merged, result.run)
380    merged.calls = { ...merged.calls, [nextModel]: (merged.calls?.[nextModel] ?? 0) + (result.run.steps ?? 0) }
381    end = result.end
382    stderr = result.stderr
383    if (result.run.sessionId !== undefined) sessionId = result.run.sessionId
384    model = nextModel
385    level = nextLevel
386    reason = result.reason
387    return result
388  }
389
390  const wait = async (n: number): Promise<boolean> => {
391    if (o.signal?.aborted === true) return false
392    try {
393      await $.clock.sleep(RETRY_WAIT_MS * n, o.signal === undefined ? undefined : { signal: o.signal })
394      return true
395    } catch {
396      return false
397    }
398  }
399
400  // Resumes the session when there is one, else starts the task fresh. A fresh start has
401  // nothing to check, so it goes again at once instead of paying the resume wait.
402  const retryRound = async (nextModel: string, tries: number, firstWhy: string, nextLevel: string | undefined) => {
403    let why = firstWhy
404    for (let i = 0; i < tries; i++) {
405      if (reason === 'ok' || reason === 'cancelled') return
406      if (sessionId !== undefined && !(await wait(i + 1))) {
407        reason = 'cancelled'
408        return
409      }
410      const prompt = sessionId === undefined ? o.prompt : retryPrompt(reason)
411      await attempt(prompt, nextModel, sessionId, why, nextLevel)
412      why = reason
413    }
414  }
415
416  await attempt(o.prompt, o.model, o.sessionId, 'start', o.level)
417  await retryRound(o.model, Math.max(0, o.firstRetries ?? o.retries), reason, o.level)
418
419  if (reason !== 'ok' && reason !== 'cancelled' && o.fallbacks !== undefined && o.fallbacks.length > 0) {
420    // A function, so each rung reads what the last one's attempts set.
421    const settled = () => reason === 'ok' || reason === 'cancelled'
422    for (const rung of o.fallbacks) {
423      if (settled()) break
424      const prompt = sessionId === undefined ? o.prompt : retryPrompt(reason)
425      await attempt(prompt, rung.model, sessionId, 'fallback', rung.level)
426      await retryRound(rung.model, Math.max(0, rung.retries ?? o.retries), reason, rung.level)
427    }
428  } else {
429    const fallback = o.fallback ?? ''
430    if (reason !== 'ok' && reason !== 'cancelled' && fallback !== '' && fallback !== model) {
431      const prompt = sessionId === undefined ? o.prompt : retryPrompt(reason)
432      await attempt(prompt, fallback, sessionId, 'fallback', o.level)
433      await retryRound(fallback, Math.max(0, o.retries), reason, o.level)
434    }
435  }
436
437  if (reason === 'ok' && o.verify !== undefined && o.verify.trim() !== '') {
438    await o.onStage?.('verifying')
439    let check = await runVerify($, o.verify, o.cwd)
440    let rounds = 0
441    const max = Math.max(0, o.verifyRounds)
442    while (!check.passed && rounds < max && sessionId !== undefined && o.signal?.aborted !== true) {
443      rounds++
444      await attempt(verifyPrompt(o.verify, check.output), model, sessionId, 'verify', level)
445      if (reason !== 'ok') break
446      check = await runVerify($, o.verify, o.cwd)
447    }
448    await o.onStage?.(undefined)
449    verify = { passed: check.passed, output: check.output, rounds }
450    if (o.signal?.aborted === true) reason = 'cancelled'
451    else if (!check.passed) reason = 'failed'
452  }
453
454  return { run: merged, end, stderr, sessionId, model, attempts, reason, verify, ms: Date.now() - started }
455}
456
457export function formatReport(o: {
458  model: string
459  level?: string
460  wanted?: string
461  short: string
462  run: Run
463  end: RunEnd
464  stderr: string
465  free?: boolean
466  attempts?: number
467  verify?: VerifyResult
468  warning?: string
469}): string {
470  const { model, level, wanted, short, run, end, stderr } = o
471  const failed = end.code !== 0 || (run.text === '' && run.errors.length > 0)
472  const verify = o.verify === undefined ? undefined : o.verify.passed ? 'verify: passed' : `verify: FAILED after ${o.verify.rounds ?? 0} rounds\n${o.verify.output.slice(-1500)}`
473  return [
474    `model: ${model}${level ? ` · reasoning ${level}` : wanted ? ` · reasoning default (${short} has no ${wanted} level)` : ''}`,
475    o.attempts !== undefined && o.attempts > 1 ? `attempts: ${o.attempts}` : undefined,
476    o.warning ? `warning: ${o.warning}` : undefined,
477    run.sessionId ? `sessionId: ${run.sessionId}` : undefined,
478    `exit: ${end.signal ?? end.code}`,
479    `cost: $${run.cost.toFixed(4)}${o.free ? ' (free model)' : ''}`,
480    run.tools.length ? `actions (${run.tools.length}):\n${run.tools.map(t => `- ${t}`).join('\n')}` : undefined,
481    run.errors.length ? `errors:\n${run.errors.join('\n')}` : undefined,
482    failed && stderr.trim() ? `stderr:\n${stderr.trim()}` : undefined,
483    verify,
484    `answer:\n${run.text || '(no text answer)'}`,
485  ]
486    .filter(Boolean)
487    .join('\n\n')
488}
489
490// Today's date where the person is, not UTC.
491export function localDate(ms: number): string {
492  const d = new Date(ms)
493  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
494}
495
496export async function dailySpent($: EngineInterface, day: string): Promise<number> {
497  const held = await Promise.resolve($.store.get('spendDaily')).catch(() => undefined)
498  if (held === null || typeof held !== 'object') return 0
499  const value = held as { date?: unknown; usd?: unknown }
500  return value.date === day && typeof value.usd === 'number' ? value.usd : 0
501}
502
503export function budgetMessage(budget: number, spent: number): string {
504  return `Daily OpenCode budget $${budget.toFixed(2)} reached ($${spent.toFixed(2)} spent today). Raise it in the plugin settings or use a free model.`
505}
506
507export async function attachedChars($: EngineInterface, files: readonly string[]): Promise<number> {
508  let chars = 0
509  for (const file of files) {
510    const stat = await Promise.resolve($.fs.stat(file)).catch(() => undefined)
511    if (stat !== undefined && typeof stat.size === 'number') chars += stat.size
512  }
513  return chars
514}
515
516export type ContextVerdict = { level: 'ok' | 'warn' | 'over'; estimate: number; limit: number }
517
518// Roughly four characters a token. Unknown models have no window to compare against.
519export function contextVerdict(chars: number, model: WorkerModel | undefined): ContextVerdict | undefined {
520  if (model === undefined || !(model.context > 0)) return undefined
521  const estimate = Math.ceil(chars / 4)
522  const ratio = estimate / model.context
523  return { level: ratio > 0.8 ? 'over' : ratio >= 0.5 ? 'warn' : 'ok', estimate, limit: model.context }
524}
525
526export function overContext(verdict: ContextVerdict): string {
527  return `This task is about ${verdict.estimate} tokens, over 80% of the model's ${verdict.limit}-token context window. Trim the prompt or files, or pick a larger-context model.`
528}
529
530export function contextWarning(verdict: ContextVerdict): string {
531  return `this task is about ${verdict.estimate} tokens, ${Math.round((verdict.estimate / verdict.limit) * 100)}% of the model's ${verdict.limit}-token context window.`
532}
533
534// A notification channel is optional, and the test kit may leave it out: never let it fail a batch.
535export function notify($: EngineInterface, text: string, title: string) {
536  try {
537    void $.ui.notify(text, { title }).catch(() => undefined)
538  } catch {
539    // No ui.notify beneath this environment.
540  }
541}
542
hooks/git.ts 70 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Batch, BatchTask } from '../types'
4
5export type GitResult = { ok: boolean; text: string }
6
7export type TaskAction = 'diff' | 'merge' | 'discard'
8
9const CAP = 60_000
10
11// Batches made before top/base were recorded still know their worktree's folder; the
12// folder is `<top>-opencode/<batch>-<task>`, so its head is the repo.
13export function repoOf(batch: Batch, task: BatchTask) {
14  if (batch.top !== undefined) return { top: batch.top, base: batch.base }
15  const at = task.dir === undefined ? -1 : task.dir.indexOf('-opencode/')
16  return at > 0 ? { top: task.dir?.slice(0, at), base: batch.base } : { top: batch.top, base: batch.base }
17}
18
19async function git($: EngineInterface, argv: string[]) {
20  const out = await $.process
21    .run(argv)
22    .catch(err => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
23  return { code: out.exitCode, stdout: out.stdout, stderr: out.stderr }
24}
25
26function cut(text: string) {
27  return text.length <= CAP ? text : `${text.slice(0, CAP)}\n… (cut)`
28}
29
30export async function branchDiff($: EngineInterface, top: string, base: string, branch: string): Promise<GitResult> {
31  const stat = await git($, ['git', '-C', top, 'diff', '--stat', `${base}...${branch}`])
32  const diff = await git($, ['git', '-C', top, 'diff', `${base}...${branch}`])
33  const ok = stat.code === 0 && diff.code === 0
34  const text = [stat.stdout.trim(), diff.stdout.trim(), stat.stderr.trim(), diff.stderr.trim()].filter(t => t !== '').join('\n\n')
35  return { ok, text: cut(text || (ok ? 'No changes.' : 'git diff failed.')) }
36}
37
38export async function mergeBranch($: EngineInterface, top: string, branch: string): Promise<GitResult> {
39  const status = await git($, ['git', '-C', top, 'status', '--porcelain'])
40  if (status.code !== 0) return { ok: false, text: status.stderr.trim() || `git -C ${top} status failed` }
41  if (status.stdout.trim() !== '') {
42    return { ok: false, text: `The main checkout has uncommitted changes; commit or stash them first.\n${status.stdout.trim()}` }
43  }
44
45  const merge = await git($, ['git', '-C', top, 'merge', '--no-ff', '--no-edit', branch])
46  if (merge.code === 0) return { ok: true, text: (merge.stdout + merge.stderr).trim() || `Merged ${branch}.` }
47
48  const conflicts = await git($, ['git', '-C', top, 'diff', '--name-only', '--diff-filter=U'])
49  await git($, ['git', '-C', top, 'merge', '--abort'])
50  const files = conflicts.stdout.trim()
51  return {
52    ok: false,
53    text: [`Merge of ${branch} failed${files === '' ? '' : `; conflicting files:\n${files}`}.`, (merge.stdout + merge.stderr).trim()]
54      .filter(t => t !== '')
55      .join('\n'),
56  }
57}
58
59export async function discardTask($: EngineInterface, top: string, folder: string, branch: string): Promise<GitResult> {
60  const removed = await git($, ['git', '-C', top, 'worktree', 'remove', '--force', folder])
61  const deleted = await git($, ['git', '-C', top, 'branch', '-D', branch])
62  const ok = removed.code === 0 && deleted.code === 0
63  const text = [removed.stdout, removed.stderr, deleted.stdout, deleted.stderr].map(t => t.trim()).filter(t => t !== '').join('\n')
64  return { ok, text: text || (ok ? `Discarded ${branch}.` : `Could not discard ${branch}.`) }
65}
66
67export function worktreeFolder(top: string, batchId: string, taskId: string) {
68  return `${top}-opencode/${batchId}-${taskId}`
69}
70
hooks/health.ts 42 lines
1import type { ModelHealth } from '../types'
2
3const MAX_MODELS = 60
4
5// How one attempt ended: only four outcomes count against a model's health.
6// "verify" is a resume after a passed run whose check failed, so the run itself was ok.
7function counted(reason: string): keyof Pick<ModelHealth, 'ok' | 'failed' | 'rateLimited' | 'hung'> | undefined {
8  if (reason === 'cancelled') return undefined
9  if (reason === 'ok' || reason === 'verify' || reason === 'start') return 'ok'
10  if (reason === 'rate-limited') return 'rateLimited'
11  if (reason === 'hung') return 'hung'
12  return 'failed'
13}
14
15export function recordRun(
16  prev: Record<string, ModelHealth>,
17  model: string,
18  reason: string,
19  ms: number,
20  error?: string,
21): Record<string, ModelHealth> {
22  const kind = counted(reason)
23  if (kind === undefined) return prev
24
25  const old = prev[model] ?? { ok: 0, failed: 0, rateLimited: 0, hung: 0, totalMs: 0, lastAt: 0 }
26  const next: ModelHealth = {
27    ...old,
28    [kind]: old[kind] + 1,
29    totalMs: old.totalMs + Math.max(0, ms),
30    lastAt: Date.now(),
31  }
32  if (error !== undefined && error.trim() !== '' && kind !== 'ok') next.lastError = error.slice(0, 500).trim()
33
34  const all: Record<string, ModelHealth> = { ...prev, [model]: next }
35  const keys = Object.keys(all)
36  if (keys.length <= MAX_MODELS) return all
37
38  const oldest = keys.reduce((a, b) => ((all[b]?.lastAt ?? 0) < (all[a]?.lastAt ?? 0) ? b : a))
39  delete all[oldest]
40  return all
41}
42
hooks/ladder.ts 43 lines
1import type { Effort, ModelHealth, WorkerModel } from '../types'
2import { isFree } from './catalog'
3import { geminiPreset, planPreset, presetNamed, rate } from './presets'
4
5export type Rung = { model: string; effort?: Effort; retries?: number }
6
7const runs = (h: ModelHealth) => h.ok + h.failed + h.rateLimited + h.hung
8
9// The `ladder` profile: the models a run climbs, cheapest first. The first rung is the
10// starting model; runResilient moves to the next after the current one's retries fail.
11// The subscriptions' rungs sit between the free and paid ones: Gemini (Google AI Pro), then the
12// Z.AI Coding Plan when planOk, its quota having room.
13export function ladder(models: WorkerModel[], health: Record<string, ModelHealth>, planOk = true): Rung[] {
14  const free = models
15    .map((m, i) => ({ m, i }))
16    .filter(({ m, i }) => isFree(m) && models.findIndex(x => x.id === m.id) === i)
17    .map(({ m, i }) => {
18      const h = health[m.id]
19      return { model: m.id, rating: rate(h), share: h === undefined ? 0 : h.ok / Math.max(1, runs(h)), i }
20    })
21    .filter(x => x.rating === 'good' || x.rating === 'unknown')
22    .sort((a, b) => {
23      if (a.rating !== b.rating) return a.rating === 'good' ? -1 : 1
24      if (a.rating === 'good') return b.share - a.share || a.i - b.i
25      return a.i - b.i
26    })
27    .slice(0, 2)
28
29  const rungs: Rung[] = free.map(x => ({ model: x.model, effort: 'high', retries: 1 }))
30  const add = (model: string, effort?: Effort) => {
31    if (!rungs.some(r => r.model === model)) rungs.push({ model, effort })
32  }
33  const gemini = geminiPreset(models)
34  if (gemini !== undefined && rate(health[gemini.model]) !== 'bad') rungs.push({ model: gemini.model, effort: gemini.effort, retries: 1 })
35  const plan = planPreset(models)
36  if (plan !== undefined && planOk && rate(health[plan.model]) !== 'bad') rungs.push({ model: plan.model, effort: plan.effort, retries: 1 })
37  const reliable = presetNamed('reliable')
38  const strong = presetNamed('strong')
39  if (reliable !== undefined) add(reliable.model, reliable.effort)
40  if (strong !== undefined) add(strong.model, strong.effort)
41  return rungs
42}
43
hooks/local.ts 229 lines
1import type { EngineInterface, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
2
3import type { LocalName, LocalServer } from '../types'
4
5// Decision models served on this machine over TypeSafe's POST /v1/systemone.
6export type LocalSpec = {
7  name: LocalName
8  label: string
9  port: number
10  /** path under the models dir the server runs in */
11  folder: string
12  argv: (dir: string, opts: LocalOptions) => string[]
13  env: (opts: LocalOptions) => Record<string, string>
14  /** GET path that answers 200 once weights are loaded */
15  ready: string
16  note: string
17}
18
19export type LocalOptions = { dir: string; kevRun: string; device: string; layaDevice: string; /** GiB of GPU Kev may take, the rest in RAM; 0: all on one device */ kevGpuGb: number; /** this mod's folder, for scripts/kev_split.py */ root: string }
20
21export const LOCAL: LocalSpec[] = [
22  {
23    name: 'kev',
24    label: 'Kev',
25    port: 8009,
26    folder: 'kev',
27    argv: (dir, o) => [
28      `${dir}/kev/.venv/bin/python`,
29      ...(o.kevGpuGb > 0 ? [`${o.root}/scripts/kev_split.py`] : ['-m', 'kev.serve']),
30      ...['--run', o.kevRun, '--port', '8009', '--host', '127.0.0.1', '--device', o.kevGpuGb > 0 ? 'cuda' : o.device],
31    ],
32    env: (o): Record<string, string> => (o.kevGpuGb > 0 ? { KEV_GPU_GB: String(o.kevGpuGb) } : {}),
33    ready: '/v1/models',
34    note: 'Qwen3.5 + LoRA decision model (Jared Palmer)',
35  },
36  {
37    name: 'laya',
38    label: 'Laya',
39    port: 8010,
40    folder: '.',
41    argv: dir => [`${dir}/laya-env/bin/laya-serve`],
42    env: o => ({ LAYA_HOST: '127.0.0.1', LAYA_PORT: '8010', LAYA_MODELS: 'english', LAYA_DEVICE: o.layaDevice === 'auto' ? '' : o.layaDevice }),
43    ready: '/health',
44    note: 'ModernBERT decision model, 100+ languages (Convai)',
45  },
46]
47
48export const notInstalled = (s: LocalSpec, bin: string) => `${s.label} is not installed (${bin} is missing). Run /opencode local setup for the install command.`
49
50export const localNamed = (name: string) => LOCAL.find(s => s.name === name.toLowerCase())
51
52const url = (s: LocalSpec, path: string) => `http://127.0.0.1:${s.port}${path}`
53
54// Module-level, so a reload (which kills the children with the module) starts clean.
55const children = new Map<LocalName, AsyncGenerator<ProcessSpawnChunk, ProcessSpawnResult>>()
56
57export async function ping($: EngineInterface, s: LocalSpec): Promise<boolean> {
58  try {
59    return (await $.http.fetch(url(s, s.ready))).ok
60  } catch {
61    return false
62  }
63}
64
65/** Starts the server unless one already answers, and waits up to `waitMs` for it. */
66export async function startLocal(
67  $: EngineInterface,
68  s: LocalSpec,
69  opts: LocalOptions,
70  set: (state: Partial<LocalServer>) => Promise<void>,
71  waitMs = 600_000,
72): Promise<{ ok: true } | { ok: false; error: string }> {
73  if (await ping($, s)) {
74    await set({ status: 'ready', error: undefined })
75    return { ok: true }
76  }
77  // One server loads at a time: two loading together spike RAM and GPU memory at once.
78  const turn = starting
79  let done = () => {}
80  starting = new Promise<void>(r => (done = r))
81  try {
82    await turn
83    return await launch($, s, opts, set, waitMs)
84  } finally {
85    done()
86  }
87}
88
89let starting: Promise<void> = Promise.resolve()
90
91async function launch(
92  $: EngineInterface,
93  s: LocalSpec,
94  opts: LocalOptions,
95  set: (state: Partial<LocalServer>) => Promise<void>,
96  waitMs: number,
97): Promise<{ ok: true } | { ok: false; error: string }> {
98  if (await ping($, s)) {
99    await set({ status: 'ready', error: undefined })
100    return { ok: true }
101  }
102  if (!children.has(s.name)) {
103    const bin = s.argv(opts.dir, opts)[0] ?? ''
104    if ((await $.fs.stat(bin).catch(() => undefined)) === undefined) {
105      const error = notInstalled(s, bin)
106      await set({ status: 'error', error })
107      return { ok: false, error }
108    }
109    const stream = $.process.spawn({ argv: s.argv(opts.dir, opts), cwd: `${opts.dir}/${s.folder}`, env: s.env(opts) })
110    children.set(s.name, stream)
111    await set({ status: 'starting', error: undefined, startedAt: Date.now() })
112    void pump(stream, s.name, set)
113  }
114  const until = Date.now() + waitMs
115  while (Date.now() < until) {
116    if (!children.has(s.name)) return { ok: false, error: lastError.get(s.name) ?? `${s.label} exited while starting.` }
117    if (await ping($, s)) {
118      await set({ status: 'ready' })
119      return { ok: true }
120    }
121    await $.clock.sleep(1500)
122  }
123  return { ok: false, error: `${s.label} did not answer on port ${s.port} within ${Math.round(waitMs / 1000)}s; it may still be downloading weights.` }
124}
125
126const lastError = new Map<LocalName, string>()
127
128async function pump(stream: AsyncGenerator<ProcessSpawnChunk, ProcessSpawnResult>, name: LocalName, set: (state: Partial<LocalServer>) => Promise<void>) {
129  let tail = ''
130  try {
131    for (;;) {
132      const r = await stream.next()
133      if (r.done) {
134        const how = r.value.signal ?? `exit ${r.value.code}`
135        const error = r.value.signal === null && r.value.code !== 0 ? `${how}: ${lastLines(tail)}` : undefined
136        if (error) lastError.set(name, error)
137        await set({ status: error ? 'error' : 'off', error })
138        break
139      }
140      tail = (tail + r.value.text).slice(-4000)
141    }
142  } catch (err) {
143    const error = err instanceof Error ? err.message : String(err)
144    lastError.set(name, error)
145    await set({ status: 'error', error })
146  } finally {
147    children.delete(name)
148  }
149}
150
151const lastLines = (text: string) => text.trim().split('\n').slice(-3).join(' | ').slice(0, 400)
152
153/** Stops a server this module started; one started elsewhere is left alone. */
154export async function stopLocal(name: LocalName): Promise<boolean> {
155  const stream = children.get(name)
156  if (!stream) return false
157  children.delete(name)
158  await stream.return(undefined as never).catch(() => undefined)
159  return true
160}
161
162export const ownsLocal = (name: LocalName) => children.has(name)
163
164export type Question =
165  | { type: 'noul'; instructions: string }
166  | { type: 'choice'; instructions: string; criteria: Record<string, string | null> | string[] }
167  | { type: 'score'; instructions: string; criteria: string[] }
168
169/** A reason the questions can't be sent, or undefined. */
170export function checkQuestions(q: unknown): string | undefined {
171  if (q === null || typeof q !== 'object' || Array.isArray(q)) return 'questions must be an object of name → question.'
172  const entries = Object.entries(q as Record<string, any>)
173  if (entries.length === 0) return 'questions is empty.'
174  for (const [key, v] of entries) {
175    if (v === null || typeof v !== 'object') return `${key}: must be an object.`
176    if (typeof v.instructions !== 'string' || v.instructions.trim() === '') return `${key}: needs instructions.`
177    if (!['noul', 'choice', 'score'].includes(v.type)) return `${key}: type must be noul, choice or score.`
178    if (v.type === 'choice' && (v.criteria === null || typeof v.criteria !== 'object' || Object.keys(v.criteria).length < 2)) return `${key}: a choice needs at least two criteria.`
179    if (v.type === 'score' && (!Array.isArray(v.criteria) || v.criteria.length < 2)) return `${key}: a score needs a criteria list of at least two levels.`
180  }
181  return undefined
182}
183
184export async function askLocal($: EngineInterface, s: LocalSpec, state: unknown, questions: unknown, model?: string): Promise<{ answers: unknown; latencyMs?: number } | { error: string }> {
185  let res: Awaited<ReturnType<EngineInterface['http']['fetch']>>
186  try {
187    res = await $.http.fetch(url(s, '/v1/systemone'), {
188      method: 'POST',
189      headers: { 'content-type': 'application/json' },
190      body: JSON.stringify({ state, questions, ...(model ? { model } : {}) }),
191    })
192  } catch (err) {
193    return { error: err instanceof Error ? err.message : String(err) }
194  }
195  let json: any
196  try {
197    json = JSON.parse(res.text)
198  } catch {
199    json = undefined
200  }
201  if (!res.ok || json?.answers === undefined) return { error: `HTTP ${res.status}: ${String(json?.detail ? JSON.stringify(json.detail) : res.text).slice(0, 400)}` }
202  return { answers: json.answers, latencyMs: typeof json.latency_ms === 'number' ? json.latency_ms : undefined }
203}
204
205/** One line per question: the top answer and how sure. */
206export function formatAnswers(answers: unknown): string {
207  if (answers === null || typeof answers !== 'object') return String(answers)
208  return Object.entries(answers as Record<string, any>)
209    .map(([key, a]) => {
210      if (a?.type === 'noul') return `${key}: yes ${pct(a.noul)}`
211      if (a?.type === 'choice') return `${key}: ${a.choice} (${dist(a.probabilities)})`
212      if (a?.type === 'score') {
213        const top = a.legend?.[String(Math.round(a.score))]
214        return `${key}: ${Number(a.score).toFixed(2)}${top ? ` ≈ ${top}` : ''} (${dist(a.probabilities, a.legend)})`
215      }
216      return `${key}: ${JSON.stringify(a)}`
217    })
218    .join('\n')
219}
220
221const pct = (p: unknown) => (typeof p === 'number' ? `${Math.round(p * 100)}%` : '?')
222const dist = (probs: unknown, legend?: Record<string, string>) =>
223  probs && typeof probs === 'object'
224    ? Object.entries(probs as Record<string, number>)
225        .sort((a, b) => b[1] - a[1])
226        .map(([k, p]) => `${legend?.[k] ?? k} ${pct(p)}`)
227        .join(', ')
228    : ''
229