Delegate well-scoped tasks to opencode workers and watch them live in a pane

A Claude Code plugin that lets Claude delegate well-scoped tasks to opencode workers running a cheaper model, and follow their progress live in a side pane.
Claude keeps the planning and the review; the worker does the mechanical part. Each worker reports its activity, the files it changed with their diffs, its token usage and its cost, and can be sent follow-up instructions in the same session.
opencode -s command to resume any worker's session in your own terminal.PATHopencode-go/deepseek-v4.1-flash)/plugin marketplace add ricardo-lobo/opencode-workers
/plugin install opencode-workers@lobo-mods
Restart Claude Code, or run /reload-plugins, to load it.
Ask Claude to delegate something, for example "delegate converting these five test files to Pest to opencode". Claude writes the brief, starts the worker, then collects and verifies the result.
The plugin adds four tools that Claude calls:
| Tool | Purpose |
|---|---|
delegate | Start a worker. Takes prompt (required), and optionally title, cwd, model and agent (for example plan for a read-only worker). |
status | Report on one worker in full, or list all of them. wait_seconds (up to 600) blocks until the worker finishes. |
follow_up | Send another instruction to a finished worker's session. |
stop | Stop a running worker. |
Run /opencode to open the pane. While the pane is closed, a band above the prompt shows how many workers are running or failed and reopens it.
| Option | Default | Description |
|---|---|---|
defaultModel | opencode-go/deepseek-v4.1-flash | Model used when delegate names none, as provider/model. |
extraDeniedCommands | (empty) | More shell command patterns workers may never run, comma-separated. See Stack-specific rules. |
Set them from the /plugin menu, or with claude plugin configure opencode-workers@lobo-mods.
Workers run opencode run --standalone --auto, so opencode approves every action that is not explicitly denied. The plugin passes its own deny rules through OPENCODE_CONFIG_CONTENT, and these take precedence over your global and project opencode configuration. --standalone gives each run a private opencode server, since the shared background service would not receive them.
| Area | Rule |
|---|---|
| Files | No reading or editing .env files (.env.example is allowed); no access outside the worker's folder. |
| Code that runs later | No writing to .git (the folder, or the file a worktree has in its place), .claude/, .opencode/, .husky/, .vscode/, .idea/, .github/workflows/, .gitmodules, opencode.json, .envrc, .pre-commit-config.yaml or lefthook.yml, through file tools or the shell. |
| Agent instructions | No writing to CLAUDE.md, CLAUDE.local.md, AGENTS.md, .mcp.json or .claude.json. |
| Git | No commit, push, reset, checkout, switch, restore, stash, rebase, merge, clean, branch deletion, tag, remote or config changes. |
| Hosting CLIs | No gh or glab. |
| System | No rm, sudo, ssh, scp, rsync, kill, pkill or killall. |
| Packages | No installs, updates, removals or publishing through npm, pnpm, yarn, bun, composer, pip, uv, poetry, cargo, go, gem, bundle or brew. |
The full list is DENIED_COMMANDS in hooks/register.tsx. Patterns use * as a wildcard, and opencode checks each command of a && chain separately.
An agent's own permissions take precedence over the top-level ones in opencode, so the rules are repeated on every built-in agent (build, plan, general, explore) and on the agent delegate names. A project's opencode.json cannot loosen them, and subagents a project defines are refused.
The defaults hold no framework commands. Add your stack's destructive ones, such as migrations and database resets, to extraDeniedCommands:
| Stack | Example value |
|---|---|
| Laravel | *artisan migrate*, *artisan db:* |
| Rails | *rails db:*, *rake db:* |
| Django | *manage.py migrate*, *manage.py flush* |
| Prisma | *prisma migrate*, *prisma db push* |
A worker may only run in the session folder or in one of its repository's git worktrees. Folders are compared by physical path, so a symlink cannot lead outside them.
The rules match command patterns. They are a guard rail, not a sandbox:
curl are allowed;find -delete, mv) can get around a pattern;.env rules cover reading and editing the file itself; search tools such as grep skip it only when it is in .gitignore.Review a worker's diffs before keeping them. Setting OPENCODE_CONFIG_CONTENT yourself has no effect on workers, since the plugin replaces it.
A worker sends everything it reads to its model provider: with the default model, DeepSeek through opencode-go. Do not run workers on folders holding data you would not share with that provider.
claude plugin validate .
claude plugin test .
hooks/register.tsx 900 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FileChange, Worker, WorkerEvent } from '../types'
5
6const PANE = 'opencode-workers'
7const EVENT_CAP = 200
8const PATCH_CAP = 30_000
9const DIFF_LINES_SHOWN = 120
10const SESSION_ID = /^ses_[A-Za-z0-9]+$/
11
12/**
13 * Shell commands a worker may never run. `--auto` approves everything that is
14 * not explicitly denied, and opencode checks each command of a `&&` chain.
15 */
16export const DENIED_COMMANDS = [
17 'git push*', 'git commit*', 'git reset*', 'git checkout*', 'git switch*', 'git restore*', 'git stash*',
18 'git rebase*', 'git merge*', 'git clean*', 'git branch -d*', 'git branch -D*', 'git tag*', 'git remote*',
19 'git config*', 'git -C*', 'git -c*', 'gh *', 'glab *',
20 // shells and wrappers that would hide a command from the patterns
21 'sh -c*', 'bash -c*', 'zsh -c*', 'xargs *', 'env *', 'command *',
22 'rm *', '*/rm *', '*find * -delete*', 'sudo *', 'ssh *', 'scp *', 'rsync *', 'kill *', 'pkill *', 'killall *',
23 'composer require*', 'composer install*', 'composer update*', 'composer remove*',
24 'npm install*', 'npm i', 'npm i *', 'npm uninstall*', 'npm update*', 'npm publish*',
25 'pnpm add*', 'pnpm install*', 'pnpm i', 'pnpm i *', 'pnpm remove*', 'pnpm update*',
26 'yarn', 'yarn add*', 'yarn install*', 'yarn remove*', 'bun add*', 'bun install*', 'bun remove*', 'bun update*',
27 'pip install*', 'pip uninstall*', 'pip3 install*', 'uv add*', 'uv remove*', 'uv pip install*',
28 'poetry add*', 'poetry install*', 'poetry remove*',
29 'cargo add*', 'cargo install*', 'cargo remove*', 'cargo publish*', 'go get*', 'go install*',
30 'gem install*', 'bundle add*', 'bundle install*', 'brew *',
31 '* .env*', '*/.env*', '*<.env*',
32]
33
34/**
35 * Paths whose contents run later, outside the worker (git hooks, Claude Code
36 * and editor settings, CI, opencode's own config): no worker may write them.
37 */
38export const PROTECTED_PATHS = [
39 '*.git/*', '*.claude/*', '*.opencode/*', '*.husky/*', '*.vscode/*', '*.idea/*', '*.github/workflows/*',
40 // a worktree's .git is a file naming its gitdir: repointed, it brings its own hooks
41 '*.git', '*.gitmodules', '*opencode.json', '*opencode.jsonc', '*.envrc',
42 '*.pre-commit-config.yaml', '*lefthook.yml', '*lefthook.yaml',
43 // what Claude and later workers read as instructions or servers
44 '*.mcp.json', '*.claude.json', '*CLAUDE.md', '*CLAUDE.local.md', '*AGENTS.md',
45]
46
47/**
48 * The opencode permission rules every worker runs under, passed inline so they
49 * win over the user's and the project's own config. `extraDenied` holds the
50 * stack's own commands, from the `extraDeniedCommands` option.
51 */
52export const permissionsFor = (extraDenied: readonly string[]) => ({
53 external_directory: 'deny',
54 read: { '*.env': 'deny', '*.env.*': 'deny', '*.env.example': 'allow' },
55 edit: {
56 '*.env': 'deny',
57 '*.env.*': 'deny',
58 '*.env.example': 'allow',
59 ...Object.fromEntries(PROTECTED_PATHS.map(p => [p, 'deny'])),
60 },
61 bash: Object.fromEntries(
62 [...DENIED_COMMANDS, ...PROTECTED_PATHS.map(p => (p.endsWith('*') ? p : `${p}*`)), ...extraDenied].map(c => [
63 c,
64 'deny',
65 ]),
66 ),
67})
68
69const BUILT_IN_AGENTS = ['build', 'plan', 'general', 'explore']
70
71/**
72 * The inline opencode config of a worker. An agent's own permissions win over
73 * the top-level ones, so the rules are repeated on every built-in agent and on
74 * the chosen one; subagents a project defines are refused outright.
75 */
76export const workerConfig = (extraDenied: readonly string[], agent: string) => {
77 const rules = permissionsFor(extraDenied)
78 const agents = [...new Set([...BUILT_IN_AGENTS, agent].filter(Boolean))]
79 return {
80 permission: { ...rules, task: { '*': 'deny', general: 'allow', explore: 'allow' } },
81 agent: Object.fromEntries(agents.map(a => [a, { permission: rules }])),
82 }
83}
84
85/** The patterns of a comma-separated option value. */
86export const patternsOf = (value: string) =>
87 value
88 .split(',')
89 .map(p => p.trim())
90 .filter(Boolean)
91
92const workers = atom({ plugin: 'opencode-workers', key: 'workers' } as const, [])
93const selected = atom({ plugin: 'opencode-workers', key: 'selected' } as const, null)
94const seq = atom({ plugin: 'opencode-workers', key: 'seq' } as const, 0)
95const showDiffs = atom({ plugin: 'opencode-workers', key: 'showDiffs' } as const, true)
96
97type $ = EngineInterface
98type Stream = ReturnType<$['process']['spawn']>
99
100const str = (v: unknown) => (typeof v === 'string' ? v : '')
101const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
102const obj = (v: unknown): Record<string, unknown> =>
103 v !== null && typeof v === 'object' ? (v as Record<string, unknown>) : {}
104const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
105const cut = (text: string, max: number) =>
106 text.length > max ? `${text.slice(0, max - 1)}…` : text
107const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
108
109const relative = (path: string, cwd: string) =>
110 cwd && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
111
112const shortModel = (model: string) => model.split('/').pop() ?? model
113
114/** An absolute path with `.`, `..` and repeated slashes resolved (symlinks are not). */
115export const normalizePath = (path: string) => {
116 const parts: string[] = []
117 for (const part of path.split('/')) {
118 if (part === '' || part === '.') continue
119 if (part === '..') parts.pop()
120 else parts.push(part)
121 }
122 return `/${parts.join('/')}`
123}
124
125export const isInside = (path: string, root: string) => root === '/' || path === root || path.startsWith(`${root}/`)
126
127/** A model or agent name that opencode's parser cannot mistake for a flag. */
128export const isPlainName = (value: string) => value === '' || /^[^-\s]\S*$/.test(value)
129
130const shellQuote = (text: string) => `'${text.split("'").join(`'\\''`)}'`
131
132export const resumeCommand = (w: Pick<Worker, 'cwd' | 'sessionID'>) =>
133 `cd ${shellQuote(w.cwd)} && opencode -s ${w.sessionID}`
134
135/** The `opencode run` command for one run of the worker: its first prompt, or a follow-up. */
136export const argvFor = (w: Pick<Worker, 'model' | 'sessionID' | 'title' | 'agent'>, message: string) => {
137 // --standalone: the background service would not see this process's env, and so not the rules
138 const argv = ['opencode', 'run', '--standalone', '--format', 'json', '--auto', '-m', w.model]
139 if (w.sessionID) argv.push('-s', w.sessionID)
140 else argv.push('--title', w.title.replace(/^[-\s]+/, '') || 'opencode worker')
141 if (w.agent) argv.push('--agent', w.agent)
142 argv.push('--', message)
143 return argv
144}
145
146const duration = (w: Worker, now: number) => {
147 const s = Math.max(0, Math.round(((w.endedAt ?? now) - w.startedAt) / 1000))
148 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
149}
150
151const statusLabel: Record<Worker['status'], string> = {
152 running: '● running',
153 done: '✓ done',
154 failed: '✗ failed',
155 stopped: '■ stopped',
156 interrupted: '! interrupted',
157}
158
159const statusColor: Record<Worker['status'], string> = {
160 running: 'suggestion',
161 done: 'success',
162 failed: 'error',
163 stopped: 'warning',
164 interrupted: 'warning',
165}
166
167const changeMark: Record<FileChange['status'], string> = { added: 'A', modified: 'M', rewritten: 'W' }
168
169/** The hunks of a unified diff, its file headers dropped. */
170export const hunksOf = (patch: string) => {
171 const at = patch.indexOf('@@')
172 return at < 0 ? '' : patch.slice(at)
173}
174
175/** A whole-file diff for content written in one go (opencode's write tool). */
176export const writeDiff = (content: string) => {
177 const lines = content.endsWith('\n') ? content.slice(0, -1).split('\n') : content.split('\n')
178 return `@@ -0,0 +1,${lines.length} @@\n${lines.map(l => `+${l}`).join('\n')}\n`
179}
180
181const addChange = (changes: FileChange[], next: FileChange): FileChange[] => {
182 const prev = changes.find(c => c.file === next.file)
183 if (!prev) return [...changes, { ...next, patch: next.patch.slice(0, PATCH_CAP) }]
184 const merged: FileChange =
185 next.status === 'modified'
186 ? {
187 ...prev,
188 additions: prev.additions + next.additions,
189 deletions: prev.deletions + next.deletions,
190 patch: (prev.patch + next.patch).slice(0, PATCH_CAP),
191 }
192 : { ...next, status: prev.status === 'added' ? 'added' : next.status, patch: next.patch.slice(0, PATCH_CAP) }
193 return changes.map(c => (c.file === next.file ? merged : c))
194}
195
196/** One opencode tool_use part: a short line, and the file change it made, if any. */
197export const describeTool = (part: Record<string, unknown>, cwd: string) => {
198 const tool = str(part.tool) || 'tool'
199 const state = obj(part.state)
200 const input = obj(state.input)
201 const target =
202 str(input.path) ||
203 str(input.filePath) ||
204 str(input.file_path) ||
205 str(input.command) ||
206 str(input.pattern) ||
207 str(input.url) ||
208 str(input.query) ||
209 str(state.title)
210 const label = cut(oneLine(`${tool} ${relative(target, cwd)}`), 160)
211 const isError = str(state.status) === 'error'
212 const path = str(input.path) || str(input.filePath) || str(input.file_path)
213 const changes: FileChange[] = []
214
215 if (!isError) {
216 // edit / patch tools carry real unified diffs in their metadata
217 for (const f of arr(obj(obj(state.metadata).metadata).files)) {
218 const one = obj(f)
219 const file = relative(str(one.file) || path, cwd)
220 const patch = hunksOf(str(one.patch))
221 if (!file || !patch) continue
222 changes.push({
223 file,
224 status: str(one.status) === 'added' ? 'added' : 'modified',
225 additions: num(one.additions),
226 deletions: num(one.deletions),
227 patch,
228 })
229 }
230 if (changes.length === 0 && tool === 'write' && path) {
231 const content = str(input.content)
232 const isNew = /created/i.test(str(state.output))
233 changes.push({
234 file: relative(path, cwd),
235 status: isNew ? 'added' : 'rewritten',
236 additions: content ? content.split('\n').length : 0,
237 deletions: 0,
238 patch: writeDiff(content),
239 })
240 }
241 }
242
243 return { label, changes, isError, error: isError ? str(state.error) : '' }
244}
245
246/** Folds one line of `opencode run --format json` into the worker. */
247export const applyLine = (w: Worker, line: string, at: number): Worker => {
248 let ev: Record<string, unknown>
249 try {
250 ev = obj(JSON.parse(line))
251 } catch {
252 return w
253 }
254 const part = obj(ev.part)
255 const sessionID = w.sessionID ?? (SESSION_ID.test(str(ev.sessionID)) ? str(ev.sessionID) : null)
256 const push = (e: WorkerEvent) => [...w.events, e].slice(-EVENT_CAP)
257
258 switch (str(ev.type)) {
259 case 'step_start':
260 return { ...w, sessionID, steps: w.steps + 1 }
261 case 'step_finish': {
262 const tokens = obj(part.tokens)
263 return {
264 ...w,
265 sessionID,
266 cost: w.cost + num(part.cost),
267 tokensIn: w.tokensIn + num(tokens.input),
268 tokensOut: w.tokensOut + num(tokens.output),
269 }
270 }
271 case 'tool_use': {
272 const t = describeTool(part, w.cwd)
273 const changes = t.changes.reduce(addChange, w.changes)
274 const files = changes.map(c => c.file)
275 const label = t.isError ? `${t.label} — ${cut(oneLine(t.error), 120)}` : t.label
276 return { ...w, sessionID, files, changes, events: push({ at, kind: t.isError ? 'error' : 'tool', label }) }
277 }
278 case 'text': {
279 const text = str(part.text)
280 if (!text.trim()) return { ...w, sessionID }
281 return {
282 ...w,
283 sessionID,
284 finalText: text,
285 events: push({ at, kind: 'text', label: cut(oneLine(text), 160) }),
286 }
287 }
288 case 'error': {
289 const err = obj(ev.error)
290 const message = str(obj(err.data).message) || str(err.message) || str(err.name) || line
291 return { ...w, sessionID, error: message, events: push({ at, kind: 'error', label: cut(oneLine(message), 200) }) }
292 }
293 default:
294 return { ...w, sessionID }
295 }
296}
297
298const report = (w: Worker, now: number, full: boolean) => {
299 const delta = w.changes.reduce((s, c) => [s[0]! + c.additions, s[1]! + c.deletions], [0, 0])
300 const head = `${w.id} [${w.status}] "${w.title}" — ${w.model}, ${duration(w, now)}, ${w.steps} steps, +${delta[0]}/-${delta[1]}, $${w.cost.toFixed(4)}`
301 if (!full) return head
302 const lines = [
303 head,
304 `cwd: ${w.cwd}`,
305 w.sessionID ? `opencode session: ${w.sessionID} (resume: ${resumeCommand(w)})` : '',
306 `files changed: ${w.changes.length ? w.changes.map(c => `${changeMark[c.status]} ${c.file} +${c.additions}/-${c.deletions}`).join(', ') : 'none'}`,
307 w.followUps.length ? `follow-ups sent: ${w.followUps.length}` : '',
308 w.error ? `error: ${w.error}` : '',
309 '',
310 'recent activity:',
311 ...w.events.slice(-15).map(e => ` ${e.kind === 'error' ? '✗' : e.kind === 'prompt' ? '»' : '-'} ${e.label}`),
312 '',
313 w.status === 'running' ? '(still running — final answer not yet available)' : 'final answer:',
314 w.status === 'running' ? '' : w.finalText || '(no text answer)',
315 ]
316 return lines.filter(l => l !== '').join('\n')
317}
318
319const streams = new Map<string, Stream>()
320let ticker: { cancel: () => void } | null = null
321let sessionCwd = ''
322let defaultModel = 'opencode-go/deepseek-v4.1-flash'
323let extraDenied: string[] = []
324
325// The workers this module started, as they stand now. The atom is what the
326// pane draws; this map is what a status wait reads, so it never depends on
327// when another dispatch's state writes become visible.
328const live = new Map<string, Worker>()
329
330function patch($: $, id: string, fn: (w: Worker) => Worker) {
331 const cur = live.get(id)
332 if (cur) live.set(id, fn(cur))
333 return update($, workers, list => list.map(w => (w.id === id ? (live.get(id) ?? fn(w)) : w)))
334}
335
336async function snapshot($: $) {
337 const list = await read($, workers)
338 return list.map(w => live.get(w.id) ?? w)
339}
340
341function ensureTicker($: $) {
342 if (ticker) return
343 ticker = $.clock.every(1000, () => {
344 if (streams.size === 0) {
345 ticker?.cancel()
346 ticker = null
347 }
348 $.ui.invalidate('ui.render')
349 })
350}
351
352/** Runs one `opencode run` for the worker: its first prompt, or a follow-up in its session. */
353async function run($: $, id: string, message: string) {
354 const w = live.get(id)
355 if (!w) return
356 const stream = $.process.spawn({
357 argv: argvFor(w, message),
358 cwd: w.cwd || undefined,
359 env: { OPENCODE_CONFIG_CONTENT: JSON.stringify(workerConfig(extraDenied, w.agent)) },
360 })
361 streams.set(id, stream)
362 ensureTicker($)
363 let buffer = ''
364 let stderr = ''
365 try {
366 for await (const { stream: pipe, text } of stream) {
367 if (pipe === 'stderr') {
368 stderr = (stderr + text).slice(-4000)
369 continue
370 }
371 buffer += text
372 const lines = buffer.split('\n')
373 buffer = lines.pop() ?? ''
374 const at = await $.clock.now()
375 const complete = lines.filter(l => l.trim())
376 if (complete.length) await patch($, id, cur => complete.reduce((acc, l) => applyLine(acc, l, at), cur))
377 }
378 const ended = await stream.result
379 const at = await $.clock.now()
380 await patch($, id, cur => {
381 const withTail = buffer.trim() ? applyLine(cur, buffer, at) : cur
382 if (withTail.status !== 'running') return { ...withTail, endedAt: withTail.endedAt ?? at }
383 const ok = ended.code === 0 && !withTail.error
384 return {
385 ...withTail,
386 status: ok ? 'done' : 'failed',
387 endedAt: at,
388 error: ok ? null : withTail.error ?? (oneLine(stderr).slice(-600) || `exit ${ended.code ?? ended.signal}`),
389 }
390 })
391 const final = live.get(id)
392 if (final) $.ui.toast(`opencode ${final.id} ${final.status}: ${cut(final.title, 60)}`)
393 } catch (err) {
394 const at = await $.clock.now()
395 await patch($, id, cur =>
396 cur.status === 'running'
397 ? { ...cur, status: 'failed', endedAt: at, error: `could not run opencode: ${String(err)}` }
398 : cur,
399 )
400 } finally {
401 streams.delete(id)
402 }
403}
404
405/** The session folder and every git worktree of its repository: where a worker may run. */
406async function allowedRoots($: $) {
407 const listed = await $.process
408 .run(['git', 'worktree', 'list', '--porcelain'], { cwd: sessionCwd })
409 .catch(() => null)
410 const worktrees =
411 listed?.exitCode === 0
412 ? listed.stdout.split('\n').filter(l => l.startsWith('worktree ')).map(l => l.slice('worktree '.length))
413 : []
414 const physical = await Promise.all([sessionCwd, ...worktrees].filter(Boolean).map(p => physicalPath($, p)))
415 return physical.filter((p): p is string => p !== null)
416}
417
418/** A folder with its symlinks resolved, or null when it is not one. */
419async function physicalPath($: $, path: string) {
420 const out = await $.process.run(['/bin/pwd', '-P'], { cwd: path }).catch(() => null)
421 return out?.exitCode === 0 && out.stdout.trim() ? out.stdout.trim() : null
422}
423
424/** The folder a worker runs in, resolved against the session's, or why it may not run there. */
425async function resolveCwd($: $, cwd: string): Promise<{ cwd: string } | { error: string }> {
426 if (!sessionCwd) return { error: 'the session folder is not known yet.' }
427 const asked = normalizePath(cwd.startsWith('/') ? cwd : `${sessionCwd}/${cwd}`)
428 // compared physically: a symlink inside the repo must not lead the worker out of it
429 const target = await physicalPath($, asked)
430 if (!target) return { error: `${asked} is not a folder.` }
431 const roots = await allowedRoots($)
432 return roots.some(root => isInside(target, root))
433 ? { cwd: target }
434 : { error: `${asked} is outside the session folder and its git worktrees.` }
435}
436
437type Spec = { prompt: string; title: string; cwd: string; model: string; agent: string }
438
439async function startWorker($: $, spec: Spec) {
440 const n = (await read($, seq)) + 1
441 await update($, seq, () => n)
442 const worker: Worker = {
443 id: `w${n}`,
444 sessionID: null,
445 title: cut(oneLine(spec.title || spec.prompt), 60),
446 model: spec.model || defaultModel,
447 agent: spec.agent,
448 cwd: spec.cwd || sessionCwd,
449 prompt: spec.prompt,
450 followUps: [],
451 status: 'running',
452 startedAt: await $.clock.now(),
453 endedAt: null,
454 steps: 0,
455 cost: 0,
456 tokensIn: 0,
457 tokensOut: 0,
458 files: [],
459 changes: [],
460 events: [],
461 finalText: '',
462 error: null,
463 }
464 live.set(worker.id, worker)
465 await update($, workers, list => [...list, worker].slice(-50))
466 await update($, selected, () => worker.id)
467 void run($, worker.id, worker.prompt)
468 void $.ui.open({ id: PANE, title: 'opencode' })
469 return worker
470}
471
472/** Sends another instruction to a finished worker's opencode session. */
473async function followUp($: $, id: string, message: string) {
474 const text = message.trim()
475 if (!text) return 'A follow-up needs some text.'
476 const w = (await snapshot($)).find(x => x.id === id)
477 if (!w) return `No opencode worker ${id}.`
478 if (w.status === 'running') return `${id} is still running; wait for it or stop it first.`
479 if (!w.sessionID) return `${id} has no opencode session to continue.`
480 // a worker saved by an older version may sit outside today's roots
481 const where = await resolveCwd($, w.cwd)
482 if ('error' in where) return `${id} cannot continue: ${where.error}`
483
484 const at = await $.clock.now()
485 live.set(id, { ...w, cwd: where.cwd })
486 await patch($, id, cur => ({
487 ...cur,
488 status: 'running',
489 endedAt: null,
490 error: null,
491 followUps: [...cur.followUps, text],
492 events: [...cur.events, { at, kind: 'prompt' as const, label: cut(oneLine(text), 160) }].slice(-EVENT_CAP),
493 }))
494 // The duration counts from the follow-up; earlier runs stay in steps and cost.
495 await patch($, id, cur => ({ ...cur, startedAt: at }))
496 await update($, selected, () => id)
497 void run($, id, text)
498 return `Sent a follow-up to ${id} (opencode session ${w.sessionID}).`
499}
500
501async function waitFor($: $, ids: string[] | null, seconds: number, signal?: AbortSignal) {
502 const deadline = (await $.clock.now()) + Math.min(Math.max(seconds, 0), 600) * 1000
503 for (;;) {
504 const list = await snapshot($)
505 const watched = ids ? list.filter(w => ids.includes(w.id)) : list
506 if (!watched.some(w => w.status === 'running')) return
507 if ((await $.clock.now()) >= deadline || signal?.aborted) return
508 // A hook has 10 s of its own time and a $.clock wait counts against it;
509 // the time a $ call is in flight does not, so the pause is a host sleep.
510 await $.process.run(['/bin/sleep', '1'], { timeoutMs: 5_000 })
511 }
512}
513
514async function stop($: $, id: string) {
515 const stream = streams.get(id)
516 if (!stream) return `${id} is not running.`
517 const at = await $.clock.now()
518 await patch($, id, w => ({ ...w, status: 'stopped', endedAt: at }))
519 await stream.return(undefined as never)
520 return `Stopped ${id}.`
521}
522
523export const register: Register = (on, options) => {
524 defaultModel = str((options as Record<string, unknown>).defaultModel) || defaultModel
525 extraDenied = patternsOf(str((options as Record<string, unknown>).extraDeniedCommands))
526
527 on('session.start', async ($, e, next) => {
528 const started = await next(e)
529 sessionCwd = str((e as Record<string, unknown>).cwd)
530
531 // A reload or a new process killed the children of the previous module;
532 // workers saved by an older version lack the newer fields.
533 const now = await $.clock.now()
534 await update($, workers, list =>
535 list.map(w => {
536 const filled: Worker = { ...w, agent: w.agent ?? '', followUps: w.followUps ?? [], changes: w.changes ?? [] }
537 return filled.status === 'running'
538 ? { ...filled, status: 'interrupted' as const, endedAt: now, error: 'the mod reloaded or the session restarted; the opencode process was killed' }
539 : filled
540 }),
541 )
542
543 await $.command.register({ name: 'opencode', description: 'Show the opencode workers pane' })
544
545 await $.tool.register({
546 name: 'delegate',
547 isDeferred: false,
548 description: [
549 'Start an opencode worker (a cheaper model run by the opencode CLI) on a self-contained task, in the background.',
550 'Use it for simple exploration or for code changes that follow a plan already written down: the worker has none of this conversation, so the prompt must hold every file path, decision and acceptance check it needs.',
551 'Returns at once with a worker id; the person watches progress and diffs in the opencode pane. Collect the result with the status tool (pass wait_seconds to block until it ends), then verify the work yourself (read the files, run the tests) — a worker report is a claim, not proof.',
552 'Workers running in the same folder must not touch the same files. To correct or extend a finished worker, use follow_up rather than starting over.',
553 'Deny rules stop a worker from committing, pushing or switching branches, deleting files, installing packages, reading or editing .env files, reaching outside its folder and running any command the user denied for their stack: leave those steps out of the brief and do them yourself. The rules match command patterns and are no sandbox, so review what a worker changed.',
554 ].join(' '),
555 inputSchema: {
556 type: 'object',
557 properties: {
558 prompt: { type: 'string', description: 'The full, self-contained brief for the worker.' },
559 title: { type: 'string', description: 'Short label shown in the pane (≤60 chars).' },
560 cwd: {
561 type: 'string',
562 description: 'Folder to run in, inside the session folder or one of its git worktrees; defaults to the session folder.',
563 },
564 model: { type: 'string', description: `opencode model as provider/model; default ${defaultModel}.` },
565 agent: { type: 'string', description: 'opencode agent name (e.g. "plan" for read-only); default the build agent.' },
566 },
567 required: ['prompt'],
568 },
569 })
570
571 await $.tool.register({
572 name: 'status',
573 isDeferred: false,
574 description:
575 'Report on opencode workers. With id: that worker in full (files changed, recent activity, final answer). Without id: one line per worker. wait_seconds (≤600) blocks until the worker (or, without id, every running worker) ends or the time runs out.',
576 inputSchema: {
577 type: 'object',
578 properties: {
579 id: { type: 'string', description: 'Worker id, e.g. w3.' },
580 wait_seconds: { type: 'number', description: 'Seconds to wait for it to finish, 0 for none.' },
581 },
582 },
583 })
584
585 await $.tool.register({
586 name: 'follow_up',
587 description:
588 'Send another instruction to a finished opencode worker, in the same opencode session (it keeps its context). The worker runs again in the background; collect it with status.',
589 inputSchema: {
590 type: 'object',
591 properties: {
592 id: { type: 'string', description: 'Worker id, e.g. w3.' },
593 prompt: { type: 'string', description: 'The instruction to send.' },
594 },
595 required: ['id', 'prompt'],
596 },
597 })
598
599 await $.tool.register({
600 name: 'stop',
601 description: 'Stop a running opencode worker by id.',
602 inputSchema: {
603 type: 'object',
604 properties: { id: { type: 'string' } },
605 required: ['id'],
606 },
607 })
608
609 return started
610 })
611
612 on('tool.call', { tool: 'mcp__opencode-workers__delegate' }, async ($, e) => {
613 const input = e as Record<string, unknown>
614 const prompt = str(input.prompt).trim()
615 if (!prompt) return { deny: 'delegate needs a non-empty prompt.' }
616 const model = str(input.model)
617 const agent = str(input.agent)
618 if (!isPlainName(model) || !isPlainName(agent)) return { deny: 'model and agent must be plain names, not flags.' }
619 const where = await resolveCwd($, str(input.cwd))
620 if ('error' in where) return { deny: `delegate: ${where.error}` }
621 const w = await startWorker($, { prompt, title: str(input.title), cwd: where.cwd, model, agent })
622 return {
623 result: `Started opencode worker ${w.id} (${w.model}) in ${w.cwd || 'the session folder'}. Collect it with status { id: "${w.id}", wait_seconds: 300 }.`,
624 }
625 })
626
627 on('tool.call', { tool: 'mcp__opencode-workers__status' }, async ($, e, next) => {
628 const input = e as Record<string, unknown>
629 const id = str(input.id)
630 const wait = num(input.wait_seconds)
631 const before = await snapshot($)
632 if (id && !before.some(w => w.id === id)) return { deny: `No opencode worker ${id}.` }
633 if (wait > 0) await waitFor($, id ? [id] : null, wait, next.signal)
634
635 const now = await $.clock.now()
636 const list = await snapshot($)
637 if (id) return { result: report(list.find(w => w.id === id)!, now, true) }
638 if (list.length === 0) return { result: 'No opencode workers yet.' }
639 return { result: list.map(w => report(w, now, false)).join('\n') }
640 })
641
642 on('tool.call', { tool: 'mcp__opencode-workers__follow_up' }, async ($, e) => {
643 const input = e as Record<string, unknown>
644 return { result: await followUp($, str(input.id), str(input.prompt)) }
645 })
646
647 on('tool.call', { tool: 'mcp__opencode-workers__stop' }, async ($, e) => {
648 const id = str((e as Record<string, unknown>).id)
649 return { result: await stop($, id) }
650 })
651
652 on('command.run', { command: 'opencode' }, async $ => {
653 const opened = await $.ui.open({ id: PANE, title: 'opencode', focus: true })
654 return { text: opened.isPlaced ? 'opencode pane opened.' : `opencode pane waits: ${opened.reason}` }
655 })
656
657 // While the pane is closed, a band above the prompt says how the workers
658 // stand and opens the pane again.
659 on('ui.close', async ($, e, next) => {
660 const closed = await next(e)
661 if (e.id === PANE) $.ui.invalidate('ui.render')
662 return closed
663 })
664
665 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
666 if (e.props.hasSurvey) return next(e)
667 const list = await read($, workers)
668 if (list.length === 0) return next(e)
669 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
670 if (isOpen) return next(e)
671
672 const { Box, Text, Button } = $.ui.resolve(e)
673 const running = list.filter(w => w.status === 'running').length
674 const failed = list.filter(w => w.status === 'failed' || w.status === 'interrupted').length
675 return (
676 <Box flexDirection="row" gap={1} alignItems="center">
677 <Text weight="semibold">opencode</Text>
678 <Text color={running ? 'suggestion' : undefined} dimColor={!running}>
679 {running ? `● ${running} running` : `${list.length} finished`}
680 </Text>
681 {failed > 0 && <Text color="error">✗ {failed} failed</Text>}
682 <Button
683 key="open-pane"
684 label="Open panel"
685 onPress={async () => {
686 await $.ui.open({ id: PANE, title: 'opencode', focus: true })
687 $.ui.invalidate('ui.render')
688 }}
689 />
690 </Box>
691 )
692 })
693
694 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
695 const els = $.ui.resolve(e)
696 const { Box, Text, Button, Markdown, Code } = els
697 // mobile draws no Input: the follow-up field is left out there
698 const Input = 'Input' in els ? els.Input : null
699 const list = await read($, workers)
700 const now = await $.clock.now()
701 const width = Math.max(30, e.props.bodyColumns)
702 const pick = (await read($, selected)) ?? list.at(-1)?.id ?? null
703 const current = list.find(w => w.id === pick) ?? null
704 const isDiffShown = await read($, showDiffs)
705 const running = list.filter(w => w.status === 'running').length
706 const totalCost = list.reduce((s, w) => s + w.cost, 0)
707
708 if (list.length === 0) {
709 return (
710 <Box flexDirection="column" gap={1}>
711 <Text size="heading" weight="semibold">No opencode workers yet</Text>
712 <Text dimColor>Ask Claude to delegate a well-scoped task to opencode.</Text>
713 </Box>
714 )
715 }
716
717 const delta = (w: Worker) =>
718 w.changes.reduce((s, c) => ({ add: s.add + c.additions, del: s.del + c.deletions }), { add: 0, del: 0 })
719
720 const card = (w: Worker) => {
721 const d = delta(w)
722 const isPicked = w.id === pick
723 return (
724 <Box
725 key={`card-${w.id}`}
726 flexDirection="column"
727 borderStyle="round"
728 borderColor={isPicked ? 'claude' : 'subtle'}
729 paddingX={1}
730 >
731 <Box flexDirection="row" gap={1} alignItems="center">
732 {/* outside the Button: the desktop draws a Button's children in its own color */}
733 <Text color={statusColor[w.status]} weight="semibold">{statusLabel[w.status]}</Text>
734 <Button key={`row-${w.id}`} plain onPress={() => update($, selected, () => w.id)}>
735 <Text weight={isPicked ? 'bold' : 'medium'}>{cut(w.title, Math.max(12, width - 30))}</Text>
736 </Button>
737 </Box>
738 <Box flexDirection="row" gap={1}>
739 <Text dimColor size="footnote">{w.id}</Text>
740 <Text dimColor size="footnote">{shortModel(w.model)}</Text>
741 <Text dimColor size="footnote" tabular>{duration(w, now)}</Text>
742 <Text dimColor size="footnote" tabular>{w.steps} steps</Text>
743 {w.changes.length > 0 && (
744 <Text size="footnote" tabular>
745 <Text dimColor>{w.changes.length} file{w.changes.length === 1 ? '' : 's'} </Text>
746 <Text color="diffAdded" dimColor={d.add === 0}>+{d.add}</Text>{' '}
747 <Text color="diffRemoved" dimColor={d.del === 0}>−{d.del}</Text>
748 </Text>
749 )}
750 <Text dimColor size="footnote" tabular>${w.cost.toFixed(4)}</Text>
751 </Box>
752 </Box>
753 )
754 }
755
756 const diffBlock = (c: FileChange) => {
757 const lines = c.patch.split('\n')
758 const shown = lines.length > DIFF_LINES_SHOWN ? `${lines.slice(0, DIFF_LINES_SHOWN).join('\n')}\n` : c.patch
759 return (
760 <Box key={`diff-${c.file}`} flexDirection="column" marginTop={1}>
761 <Text>
762 <Text weight="semibold">{changeMark[c.status]} {c.file}</Text>
763 <Text color="diffAdded"> +{c.additions}</Text>
764 <Text color="diffRemoved"> −{c.deletions}</Text>
765 </Text>
766 <Code source={shown} format="diff" path={c.file} />
767 {lines.length > DIFF_LINES_SHOWN && (
768 <Text dimColor size="footnote">… {lines.length - DIFF_LINES_SHOWN} more lines</Text>
769 )}
770 </Box>
771 )
772 }
773
774 const detail = (w: Worker) => (
775 <Box flexDirection="column" borderStyle="round" borderColor={statusColor[w.status]} paddingX={1} gap={1}>
776 <Box flexDirection="column">
777 <Text size="heading" weight="semibold">{w.id} · {w.title}</Text>
778 <Text dimColor size="footnote" wrap="truncate-start">{w.cwd}</Text>
779 </Box>
780
781 <Box flexDirection="row" gap={1} flexWrap="wrap">
782 {w.status === 'running' && <Button key="stop" label="Stop" onPress={() => void stop($, w.id)} />}
783 {w.status !== 'running' && (
784 <Button
785 key="repeat"
786 label="Repeat"
787 onPress={async () => {
788 // a worker saved by an older version may sit outside today's roots
789 const where = await resolveCwd($, w.cwd)
790 if ('error' in where) return $.ui.toast(`opencode: ${where.error}`)
791 await startWorker($, { prompt: w.prompt, title: w.title, cwd: where.cwd, model: w.model, agent: w.agent })
792 }}
793 />
794 )}
795 {w.sessionID && (
796 <Button
797 key="copy-resume"
798 label="Copy resume command"
799 onPress={pe =>
800 void $.ui.copy({ text: resumeCommand(w), surface: pe.surface })
801 }
802 />
803 )}
804 {w.changes.length > 0 && (
805 <Button
806 key="toggle-diffs"
807 label={isDiffShown ? 'Hide diffs' : 'Show diffs'}
808 onPress={() => update($, showDiffs, v => !v)}
809 />
810 )}
811 </Box>
812
813 <Box flexDirection="column">
814 <Text weight="semibold">Brief</Text>
815 <Text>{cut(oneLine(w.prompt), 400)}</Text>
816 {w.followUps.map((f, i) => (
817 <Text key={`fu-${i}`} color="suggestion">» {cut(oneLine(f), 300)}</Text>
818 ))}
819 </Box>
820
821 {w.changes.length > 0 && (
822 <Box flexDirection="column">
823 <Text weight="semibold">
824 Changes · {w.changes.length} file{w.changes.length === 1 ? '' : 's'}
825 </Text>
826 {isDiffShown
827 ? w.changes.map(diffBlock)
828 : w.changes.map(c => (
829 <Text key={`file-${c.file}`}>
830 {changeMark[c.status]} {c.file} <Text color="diffAdded">+{c.additions}</Text>{' '}
831 <Text color="diffRemoved">−{c.deletions}</Text>
832 </Text>
833 ))}
834 </Box>
835 )}
836
837 <Box flexDirection="column">
838 <Text weight="semibold">Activity</Text>
839 {w.events.length === 0 && <Text dimColor>waiting for the first step…</Text>}
840 {w.events.slice(-12).map((ev, i) => (
841 <Text
842 key={`ev-${i}`}
843 color={ev.kind === 'error' ? 'error' : ev.kind === 'prompt' ? 'suggestion' : undefined}
844 dimColor={ev.kind === 'tool'}
845 wrap="truncate-end"
846 >
847 {ev.kind === 'text' ? '› ' : ev.kind === 'prompt' ? '» ' : ' '}
848 {ev.label}
849 </Text>
850 ))}
851 </Box>
852
853 {w.error && <Text color="error">{cut(w.error, 600)}</Text>}
854
855 {w.status !== 'running' && w.finalText && (
856 <Box flexDirection="column">
857 <Text weight="semibold">Answer</Text>
858 <Markdown key={`answer-${w.id}`} text={w.finalText} />
859 </Box>
860 )}
861
862 {Input && w.status !== 'running' && w.sessionID && (
863 <Input
864 key={`follow-up-${w.id}`}
865 label="Follow-up"
866 placeholder="Send another instruction to this opencode session"
867 submitLabel="Send"
868 onSubmit={(value: string) => void followUp($, w.id, value)}
869 />
870 )}
871 </Box>
872 )
873
874 return (
875 <Box flexDirection="column" gap={1}>
876 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
877 <Text size="footnote" dimColor tabular>
878 {running} running · {list.length - running} finished · ${totalCost.toFixed(4)}
879 </Text>
880 {list.length > running && (
881 <Button
882 key="clear"
883 label="Clear finished"
884 onPress={async () => {
885 for (const w of list) if (w.status !== 'running') live.delete(w.id)
886 await update($, workers, l => l.filter(w => w.status === 'running'))
887 await update($, selected, () => null)
888 }}
889 />
890 )}
891 </Box>
892
893 <Box flexDirection="column" gap={1}>{[...list].reverse().map(card)}</Box>
894
895 {current && detail(current)}
896 </Box>
897 )
898 })
899}
900types/index.d.ts 51 lines1export type WorkerStatus = 'running' | 'done' | 'failed' | 'stopped' | 'interrupted'
2
3export type WorkerEvent = {
4 at: number
5 kind: 'tool' | 'text' | 'error' | 'prompt'
6 label: string
7}
8
9/** What a worker did to one file: its hunks as one unified diff body. */
10export type FileChange = {
11 file: string
12 status: 'added' | 'modified' | 'rewritten'
13 additions: number
14 deletions: number
15 patch: string
16}
17
18export type Worker = {
19 id: string
20 sessionID: string | null
21 title: string
22 model: string
23 agent: string
24 cwd: string
25 prompt: string
26 followUps: string[]
27 status: WorkerStatus
28 startedAt: number
29 endedAt: number | null
30 steps: number
31 cost: number
32 tokensIn: number
33 tokensOut: number
34 files: string[]
35 changes: FileChange[]
36 events: WorkerEvent[]
37 finalText: string
38 error: string | null
39}
40
41declare module 'claude-code' {
42 interface PluginState {
43 'opencode-workers': {
44 workers: Worker[]
45 selected: string | null
46 seq: number
47 showDiffs: boolean
48 }
49 }
50}
51