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…

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).
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.agy), signed in with the Google account that has Google AI Pro.delegate_batch worktrees.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.
npm i -g opencode-ai, then opencode auth login and choose OpenCode Go (run it again for a Z.AI Coding Plan)./opencode selftest. Every check should pass; optional parts you have not set up (the Z.AI Coding Plan, Gemini, Kev, Laya) show as skipped.agy once to sign in, for Gemini (see below)./opencode local setup prints the command that installs Kev and Laya.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.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.maxCost limits a batch, and dailyBudget limits the day's spend./opencode cancel..opencode/reports/.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.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.dependsOn build on their dependencies' branches./opencode commands cover models, reasoning, presets, batches, history and cancel.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.
plan preset and profile run on the plan's GLM-5.3 (its flash model when that is all there is).ladder profile tries the plan after the free models and before paid ones, while the plan is healthy and has quota.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.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.
gemini preset and profile run Gemini 4 Argon at high reasoning.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.review: true) use Gemini when it is connected (the reviewModel option's auto), else a free model.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.
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.
| Model | Port | What it is |
|---|---|---|
kev | 8009 | Kev, a LoRA on Qwen3.5. The default checkpoint is jaredpalmer/kev-0.8b, which fits a 4 GB GPU. |
laya | 8010 | Laya, 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.
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 panel opens when a session starts and with /opencode. It has these sections:
low, medium, high, xhigh and max levels.<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.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.
| Command | What it does | |||
|---|---|---|---|---|
/opencode | Opens 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 status | Shows the worker, chat mode, running jobs and spend. | |||
/opencode selftest | Checks the CLI, git, hang detection, rate-limit detection, notifications, the report folder, which model sources opencode lists, and Kev and Laya. | |||
/opencode batches | Lists this chat's batches and their task states. | |||
/opencode history | Lists 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 help | Lists the commands. |
| Option | What it does | Default |
|---|---|---|
defaultModel | Worker used until one is picked in the OpenCode panel (provider/model, or a bare id under opencode-go/). | opencode-go/kimi-k3 |
orchestrate | Adds a short system prompt section telling Claude to hand routine work to OpenCode workers. | true |
fallbackModel | Model a run switches to after the picked one keeps failing (429s, hangs). Empty: no fallback. | opencode-go/deepseek-v4.1-flash |
autoRetries | How many times a failed or hung opencode run is resumed before giving up (and trying the fallback model). | 3 |
stallMinutes | A run that prints nothing for this long is killed and resumed. | 10 |
reviewModel | Model that reviews batch tasks when review is on (and in goal mode). auto: Gemini when connected, else opencode/deepseek-v4.1-flash-free. | auto |
dailyBudget | No new paid runs start once today's OpenCode spend reaches this. 0: no limit. | 0 |
zaiQuota | Z.AI Coding Plan model calls per 5 hours after which no new plan runs start. 0: no limit. | 0 |
localModelsDir | Folder holding kev/ (with its .venv) and laya-env/. Empty: ~/local-models. | empty |
kevModel | Checkpoint 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 |
kevGpuGb | Above 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 |
localDevice | auto, cuda or cpu, for Kev. | auto |
layaDevice | auto, 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.
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.opencode missing or not signed in: it must be on PATH and signed in with opencode auth login.opencode auth login, pick Z.AI Coding Plan, then Refresh models.agy models lists models in a terminal (run agy to sign in if it lists none), then Refresh models. agy update updates the CLI./opencode local setup and the command it prints.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.
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.
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.
hooks/register.tsx 1574 lines1import { 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 lines1import 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}
292hooks/catalog.ts 168 lines1import 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}
168hooks/gemini.ts 192 lines1import 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}
192hooks/commands.ts 147 lines1import 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}
147hooks/chat.ts 226 lines1import 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}
226hooks/control.ts 25 lines1// 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}
25hooks/delegate.ts 542 lines1import 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}
542hooks/git.ts 70 lines1import 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}
70hooks/health.ts 42 lines1import 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}
42hooks/ladder.ts 43 lines1import 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}
43hooks/local.ts 229 lines1import 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