SLOPSHOPPER

ext-agent

Run Claude Code subagents in pi or opencode instead of a Claude model. Adds the ext-agent:worker agent type; built-in subagents stay available unless you opt…

newguardprocessagents
v1.0.1MITupdated 2026-10-02darkautism/ext-agent/plugins/ext-agent
A shopper browsing a rack in a slop shop
README

ext-agent

A Claude Code plugin that lets the Agent tool dispatch subagents to pi or opencode instead of a Claude model.

  • Adds one agent type, ext-agent:worker. Its turns are run by the pi or opencode CLI; no Claude model is called for them, so they cost no Claude tokens.
  • Keeps Claude Code's native subagent experience: background runs, the agent's name, SendMessage to continue a session, TaskStop, and the live "Ran 3 commands" view of what the worker is doing.
  • Built-in subagents (general-purpose, Explore, …) keep working. Blocking them is an explicit opt-in.
  • Optional git worktree per worker, laid out like Claude Code's own.

It is written with Claude Code's function-hooks plugin API; see plugins/ext-agent/hooks/register.ts.

Requirements

  • Claude Code with function hooks (developed against 2.1.285), and the environment variable CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (see below).
  • pi and/or opencode on your PATH, already logged in to whatever provider you want them to use.
  • git if you use worktrees. macOS or Linux (the plugin runs /bin/sh); Windows is not supported.

Install

claude plugin marketplace add darkautism/ext-agent
claude plugin install ext-agent@ext-agent

Then turn on function hooks and restart Claude Code. Plugins like this one only load when CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 is set where Claude Code starts. Either export it in your shell profile:

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

or put it in the env block of ~/.claude/settings.json:

{
  "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" }
}

If it is missing, dispatching ext-agent:worker falls back to a small Claude model that only tells you the plugin is not active; it does not run your task.

Using it

Ask Claude to use it, or call the Agent tool with subagent_type: "ext-agent:worker". The prompt may start with header lines:

model: pi:openai-codex/gpt-6-luna:high
cwd: /path/to/project
worktree: fix-login

Fix the failing login test and run the suite.
HeaderMeaning
model: pi / model: pi:<provider>/<model>[:<thinking>]Run pi. Bare pi lets pi use its own default model. Thinking is off, minimal, low, medium, high or xhigh.
model: opencode / model: opencode:<provider/model>[#variant]Run opencode. The model must appear in opencode models.
cwd: <absolute path>Directory to work in. Default: the session's directory.
worktree: <name>Work in a git worktree of that name, made or reused. The Agent call's isolation: "worktree" does the same.

Give the Agent a name to key its session: spawning the same name again, or sending it a message, continues the same pi / opencode conversation. A genuine answer ends with — answered by pi|opencode, <model>.

Run it in the background with the Agent tool's run_in_background, as with any subagent.

Permissions. pi and opencode have their own read / bash / edit / write tools and run them without Claude Code's permission prompts (opencode with --auto). A worker can change files and run commands in its directory. Use a worktree, or a directory you are happy for it to edit.

Configuration

Set these in Claude Code's plugin config menu (each option is a row there), or in settings.json under pluginConfigs, keyed by the plugin id (ext-agent@ext-agent for the install above). A change reloads the plugin.

OptionDefault
blockBuiltinfalsefalse: ext-agent:worker is added next to the built-in agent types. true: only ext-agent:worker can be dispatched; built-in types are hidden from the model and refused with a message saying how to dispatch instead.
defaultModelpiUsed when an Agent call names no model:. Same syntax as the header, e.g. pi:openai-codex/gpt-6-luna:high.
worktreeLayoutclaudeclaude: <repo>/.claude/worktrees/<name> on branch worktree-<name>, as Claude Code does; a new branch starts from the remote's default branch, or from the current HEAD if the worktree.baseRef setting is head. sibling: <repo>-wt/<name> beside the repo on branch wip/<name>, off main.

An existing worktree or branch of the same name is reused, not recreated.

Example, blocking built-in agents and using a fixed pi model:

{
  "pluginConfigs": {
    "ext-agent@ext-agent": {
      "options": { "blockBuiltin": true, "defaultModel": "pi:openai-codex/gpt-6-luna:high" }
    }
  }
}

How it works

The plugin hooks the Agent tool and the worker's model loop:

  • When a worker takes a turn, the plugin starts pi -p --mode json (or opencode run --format json) in the worker's directory, in its own process group so TaskStop or an interrupt takes down everything the CLI started.
  • Each tool call the CLI makes is replayed as one of the worker's own tool calls (Bash, Read, Write, Edit) answered with what the CLI already got, so Claude Code's subagent view shows it like any other agent's activity. The transcript keeps at most 1500 characters of each result.
  • The CLI's final text becomes the worker's answer.
  • A long worker's transcript would eventually pass the context window and end the agent with "Prompt is too long", so when Claude Code compacts a worker, the plugin drops the middle of the transcript itself (keeping the task and the last 80 messages) without asking any model. The full history stays in pi's / opencode's own session.

What it sends, runs and decides

Network. The plugin itself makes no network calls and sends nothing anywhere. The pi / opencode process it starts receives the task prompt (your header lines stripped) and reads and writes files in the worker's directory; what that process sends to its model provider is up to its own configuration, not this plugin. For an opencode model, the plugin runs opencode models once to check the name.

What it reads. For each worker turn the plugin reads that worker's own transcript ($.session.messages({ agentId })), not the main conversation, to find the newest task message and its model: / cwd: / worktree: header lines. It strips <system-reminder> blocks and hands only the task text to the pi / opencode process as its prompt. When a worker is compacted (session.compact), it reads that worker's transcript to drop the middle of it. It reads Claude Code's merged settings only for the worktree.baseRef value, and the plugin's own options. It reads no files itself; files are read and written by the pi / opencode process you started.

Programs it runs. Everything goes through two Claude Code calls: $.process.run(argv) starts a program with the given argument list, waits for it to finish and returns its output (used for the short git, test and opencode models commands below); $.process.spawn({ argv }) starts a program with the given argument list and streams its output while it runs (used only for the pi / opencode agent process, wrapped in the fixed /bin/sh script below). Neither call is given a shell string built from your input. The argument lists are assembled in hooks/register.ts from fixed program names plus the values you pass (model, directory, prompt), each as its own argument; the table lists every command shape:

ProgramWhy
pi or opencodeThe agent itself: pi -p --mode json [--model <m>] --session-id <key> <prompt>, or opencode run --standalone --auto --format json [--model <m>] --title <key> [--session <id>] <prompt>.
opencode models, opencode session list --standalone --format jsonValidate an opencode model name; find the opencode session of a worker being continued.
/bin/shA fixed wrapper cd "$1" && shift; set -m; "$@" & … that changes into the worker's directory and runs the CLI in its own process group, so TaskStop or an interrupt stops everything the CLI started. The directory and the CLI's arguments are passed as separate arguments, not spliced into the script.
/bin/sh (second use)The fixed text cd "$1" && exec opencode session list --standalone --format json, with the worker's directory as $1, to list opencode sessions from that directory.
test -d <cwd>Check the cwd: header names a directory.
git rev-parse, git worktree list, git worktree prune, git symbolic-ref, git worktree addOnly for worktree: / isolation: "worktree": find the repo, reuse or create the worktree and its branch.

It also reads Claude Code's merged settings once per worktree creation, only for the worktree.baseRef value.

Hooks and what they decide.

HookWhat it decides, and when
agent.offerOnly when blockBuiltin is on: hides every agent type not provided by this plugin from the model. Otherwise not registered.
tool.describe (Agent)Appends a short note about ext-agent:worker and its model syntax to the Agent tool's description.
turn.stepOn the main conversation: if an Agent call's model is pi… or opencode… (which the Agent tool's schema would refuse), moves it into the prompt's model: header line. On a worker's own loop: runs the CLI instead of asking a Claude model (see below). Other loops pass through unchanged.
tool.call (Agent)Only when blockBuiltin is on: refuses a subagent_type other than ext-agent:worker with a usage message. For ext-agent:worker with isolation: "worktree": removes isolation and writes a worktree: header into the prompt instead, because the engine's own worktree does not reach the CLI. Everything else passes to the next hook unchanged.
tool.call (the worker's own Bash, Read, Write, Edit)Stands in for these tools only for calls this plugin itself created to mirror what pi / opencode already did: it waits for the CLI's result and returns it. Any other tool call, from any agent, passes to the next hook unchanged.
session.compactOnly for a worker's own transcript: drops the middle of it without asking a model (see How it works). Any other compaction passes through unchanged.

The plugin never answers a permission check: Claude Code's own permission rules decide for everything it does not create. The mirrored Bash/Read/Write/Edit calls are answered from the CLI's output and execute nothing in Claude Code.

Tool input it changes: only the Agent tool's call (the model and isolation fields described above).

Limits

  • Only the Claude Code side is managed. pi and opencode have their own context limits and compaction; a very long worker session can be rejected by the provider ("Bad Request"). Start a new name for a fresh session.
  • The model you name must be one your pi / opencode is set up to use.
  • The worker's tool calls in the subagent view are a summary: long file contents and diffs are clipped.

Troubleshooting

  • The agent answers "ext-agent is not active": CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 is not set where Claude Code started, or Claude Code was not restarted after you set it.
  • "Unknown opencode model": the name is not in opencode models.
  • "ended without an answer": the CLI exited without a reply; the message includes its error or last stderr lines (often an authentication or provider error).
  • Check the plugin loads: claude plugin validate plugins/ext-agent.

License

MIT

Source 1 files
hooks/register.ts 550 lines
1import type { EngineInterface, Register, StreamHook, TurnStepChunk } from 'claude-code'
2
3/**
4 * ext-agent: adds the Agent tool's `ext-agent:worker` type, whose turns run
5 * pi or opencode instead of a Claude model. The built-in agent types stay
6 * available unless the `blockBuiltin` option is on, in which case they are
7 * hidden from the model and refused with a message saying how to dispatch
8 * instead.
9 *
10 * Each tool call pi / opencode makes becomes one of the worker's own tool
11 * calls (Bash, Read, Write, Edit), answered here with what the CLI already
12 * got, so the subagent view groups them as it does any agent's ("Ran 3
13 * commands"). What the transcript keeps of each result is clipped, and a long
14 * worker's transcript is compacted here without asking any model.
15 */
16
17const TYPE = 'ext-agent:worker'
18const HEADER = /^[ \t]*(model|cwd|worktree)[ \t]*:[ \t]*(\S.*?)[ \t]*$/i
19/** How much of a tool's output the worker's transcript keeps. */
20const KEPT = 1500
21/** Messages of a worker's transcript kept when it is compacted. */
22const KEEP_MESSAGES = 80
23
24type Config = { blockBuiltin: boolean; defaultModel: string; worktreeLayout: 'claude' | 'sibling' }
25let config: Config = { blockBuiltin: false, defaultModel: 'pi', worktreeLayout: 'claude' }
26
27const usage = (why: string) =>
28  `${why}\n\n` +
29  `Dispatch external agents with Agent(subagent_type: "${TYPE}"); the prompt may open with:\n` +
30  `  model: pi[:<provider>/<model>[:off|minimal|low|medium|high|xhigh]]   (pi's own default when no model is named)\n` +
31  `  model: opencode[:<provider/model[#variant]>]                      (\`opencode models\` lists them)\n` +
32  `  cwd: <absolute path>                                              (default: the session's directory)\n` +
33  `  worktree: <name>   (or isolation: "worktree") runs in a git worktree of that name, made or reused\n` +
34  `The default model is "${config.defaultModel}". Give the Agent a \`name\` to continue its session later.` +
35  (config.blockBuiltin ? '\nBuilt-in subagent types are disabled by this plugin\'s blockBuiltin option.' : '')
36
37const isWorker = (type: string) => type === TYPE
38
39const clip = (text: string, max = KEPT) =>
40  text.length <= max ? text : `${text.slice(0, max)}\n… (${text.length - max} more characters kept by the CLI only)`
41
42type Header = { model?: string; cwd?: string; worktree?: string; body: string }
43
44/** Takes the `model:` / `cwd:` / `worktree:` lines off the top of a prompt. */
45function headerOf(text: string): Header {
46  const lines = text.replace(/^\s*\n/, '').split('\n')
47  const out: Header = { body: '' }
48  while (lines.length > 0) {
49    const m = lines[0].match(HEADER)
50    if (!m) break
51    out[m[1].toLowerCase() as 'model' | 'cwd' | 'worktree'] = m[2]
52    lines.shift()
53  }
54  out.body = lines.join('\n').trim()
55  return out
56}
57
58/** `model` is absent when the CLI is left to pick its own default. */
59type Spec = { cli: 'pi' | 'opencode'; model?: string }
60
61let opencodeModels: Promise<Set<string>> | undefined
62
63/** `pi`, `pi:<model>`, `opencode` or `opencode:<model>`, checked. */
64async function specOf($: EngineInterface, model = config.defaultModel): Promise<Spec | { error: string }> {
65  const m = model.trim().match(/^(pi|opencode)(?::(\S+))?$/)
66  if (!m) return { error: `Model "${model}" is not "pi[:<model>]" or "opencode[:<model>]".` }
67  const [, cli, name] = m
68  if (cli === 'pi') {
69    if (name !== undefined && !/^[\w.\-]+\/[\w.\-:/]+$/.test(name) && !/^[\w.\-]+$/.test(name)) return { error: `Unusable pi model "${name}".` }
70    return { cli: 'pi', model: name }
71  }
72  if (name === undefined) return { cli: 'opencode' }
73  opencodeModels ??= $.process
74    .run(['opencode', 'models'])
75    .then(({ stdout }) => new Set(stdout.split('\n').map((l) => l.trim()).filter(Boolean)))
76  const known = await opencodeModels
77  if (known.has(name.split('#')[0])) return { cli: 'opencode', model: name }
78  opencodeModels = undefined
79  return { error: `Unknown opencode model "${name}".` }
80}
81
82/**
83 * argv run from `dir` in a process group of its own, which the shell takes
84 * down whole when the engine ends it (TaskStop, an interrupt), so no tool the
85 * CLI started outlives the worker.
86 */
87const supervised = (dir: string, argv: string[]) => [
88  '/bin/sh', '-c',
89  'cd "$1" && shift; set -m; "$@" & pid=$!; trap "kill -TERM -$pid 2>/dev/null; exit 143" TERM INT HUP; wait $pid',
90  'sh', dir, ...argv,
91]
92
93/**
94 * The session a worker's earlier turn left, by its title. Listed through a
95 * standalone server as the run itself is: the background service can hold a
96 * stale idea of which project a directory is, and would not find it.
97 */
98async function opencodeSession($: EngineInterface, cwd: string, title: string) {
99  const { stdout } = await $.process.run([
100    '/bin/sh', '-c', 'cd "$1" && exec opencode session list --standalone --format json', 'sh', cwd,
101  ])
102  try {
103    const sessions = JSON.parse(stdout) as { id: string; title?: string }[]
104    return sessions.find((s) => s.title === title)?.id
105  } catch {
106    return undefined
107  }
108}
109
110/**
111 * A worktree for a `worktree:` line. Laid out as Claude Code's own are (the
112 * default): `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, off
113 * what the `worktree.baseRef` setting says (`fresh`, the default: the remote's
114 * default branch; `head`: the current HEAD). With the `sibling` layout:
115 * `<repo>-wt/<name>` beside the repository on `wip/<name>`, off `main`.
116 * An existing worktree or branch of that name is reused.
117 */
118async function worktreeOf($: EngineInterface, cwd: string, label: string) {
119  const git = (...argv: string[]) => $.process.run(['git', '-C', cwd, ...argv])
120  const top = await git('rev-parse', '--show-toplevel')
121  if (top.exitCode !== 0) return { error: `${cwd} is not in a git repository` }
122  const repo = top.stdout.trim()
123  const sibling = config.worktreeLayout === 'sibling'
124  const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'worker'
125  const path = sibling ? `${repo}-wt/${slug}` : `${repo}/.claude/worktrees/${slug}`
126  const branch = sibling ? `wip/${slug}` : `worktree-${slug}`
127  // Forget worktrees whose directories were deleted, so their paths and
128  // branches can be used again.
129  await git('worktree', 'prune')
130  const listed = await git('worktree', 'list', '--porcelain')
131  if (listed.stdout.split('\n').includes(`worktree ${path}`)) return { path }
132  const exists = await git('rev-parse', '--verify', '-q', `refs/heads/${branch}`)
133  let argv: string[]
134  if (exists.exitCode === 0) argv = [path, branch]
135  else {
136    let base: string | undefined
137    if (sibling) {
138      if ((await git('rev-parse', '--verify', '-q', 'main')).exitCode === 0) base = 'main'
139    } else if (((await $.settings.read()) as { worktree?: { baseRef?: string } }).worktree?.baseRef !== 'head') {
140      const remote = await git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD')
141      if (remote.exitCode === 0) base = remote.stdout.trim()
142    }
143    argv = ['-b', branch, path, ...(base ? [base] : [])]
144  }
145  const added = await git('worktree', 'add', '-q', ...argv)
146  if (added.exitCode !== 0) return { error: added.stderr.trim() || `git worktree add exited ${added.exitCode}` }
147  return { path }
148}
149
150/**
151 * An Agent call's arguments with a `model` the schema would refuse
152 * (`pi…`, `opencode…`) moved into the prompt's `model:` line, so the
153 * engine's validation sees a call it accepts. Unparseable JSON passes as is.
154 */
155function movedModel(json: string) {
156  let input: Record<string, unknown>
157  try { input = JSON.parse(json) } catch { return json }
158  const model = input.model
159  if (typeof model !== 'string' || !/^(pi|opencode)(:|$)/.test(model)) return json
160  const prompt = typeof input.prompt === 'string' ? input.prompt : ''
161  const rest = prompt.replace(/^\s*model[ \t]*:.*(\n|$)/i, '')
162  const { model: _, ...kept } = input
163  return JSON.stringify({ ...kept, prompt: `model: ${model}\n${rest}` })
164}
165
166// ---------------------------------------------------------------------------
167// A run: one CLI process answering one turn of a worker, read piece by piece
168// by whichever dispatch is waiting on it (a step, or a tool call's result).
169
170type Call = { name: 'Bash' | 'Read' | 'Write' | 'Edit'; input: Record<string, unknown> }
171type Event = { kind: 'show'; text: string } | ({ kind: 'tool'; id: string } & Call)
172type Outcome = { output: string; isError: boolean }
173
174type Run = {
175  spec: Spec
176  stream: AsyncGenerator<{ stream: 'stdout' | 'stderr'; text: string }, { code: number | null; signal: string | null }>
177  events: Event[]
178  calls: Map<string, Call>
179  outcomes: Map<string, Outcome>
180  /** The CLI's own id for each call, to ours. */
181  ids: Map<string, string>
182  buffer: string
183  stderr: string
184  error: string
185  answered: string
186  answer: string
187  current: string
188  texts: Map<string, string>
189  lastMessage: string
190  ended?: string
191}
192
193const runs = new Map<string, Run>()
194const ours = new Set<string>()
195/** The header each worker was spawned with, past the 4096 messages a read returns. */
196const spawned = new Map<string, Header>()
197let serial = 0
198
199/** One of the worker's own tool calls standing for a CLI's. */
200function callOf(tool: string, args: Record<string, any> = {}): Call {
201  const path = String(args.path ?? args.filePath ?? args.file_path ?? '')
202  switch (tool.toLowerCase()) {
203    case 'bash':
204    case 'shell':
205      return { name: 'Bash', input: { command: String(args.command ?? '') } }
206    case 'read':
207      return { name: 'Read', input: { file_path: path, ...(args.offset ? { offset: args.offset } : {}), ...(args.limit ? { limit: args.limit } : {}) } }
208    case 'write':
209      return { name: 'Write', input: { file_path: path, content: clip(String(args.content ?? ''), 400) } }
210    case 'edit': {
211      const first = Array.isArray(args.edits) ? args.edits[0] ?? {} : args
212      return {
213        name: 'Edit',
214        input: {
215          file_path: path,
216          old_string: clip(String(first.oldText ?? first.oldString ?? ''), 400),
217          new_string: clip(String(first.newText ?? first.newString ?? ''), 400),
218        },
219      }
220    }
221    default:
222      return { name: 'Bash', input: { command: clip(`${tool} ${JSON.stringify(args)}`, 400) } }
223  }
224}
225
226/** A CLI's tool output as the worker's own tool records a result. */
227function resultOf(call: Call, output: string): unknown {
228  const kept = clip(output)
229  const file_path = String(call.input.file_path ?? '')
230  switch (call.name) {
231    case 'Bash':
232      return { stdout: kept, stderr: '', interrupted: false }
233    case 'Read': {
234      const numLines = kept.split('\n').length
235      return { type: 'text', file: { filePath: file_path, content: kept, numLines, startLine: Number(call.input.offset ?? 1), totalLines: numLines } }
236    }
237    case 'Write':
238      return { type: 'create', filePath: file_path, content: '', structuredPatch: [], originalFile: null }
239    case 'Edit':
240      return {
241        filePath: file_path, oldString: call.input.old_string, newString: call.input.new_string,
242        originalFile: null, structuredPatch: [], userModified: false, replaceAll: false,
243      }
244  }
245}
246
247const textOf = (content: unknown) =>
248  Array.isArray(content) ? content.map((c: any) => (typeof c?.text === 'string' ? c.text : '')).join('') : String(content ?? '')
249
250function called(run: Run, cliId: string, tool: string, args: Record<string, any>) {
251  const id = `toolu_ext_${Date.now().toString(36)}_${(serial++).toString(36)}`
252  const call = callOf(tool, args)
253  ours.add(id)
254  run.ids.set(cliId, id)
255  run.calls.set(id, call)
256  run.events.push({ kind: 'tool', id, ...call })
257  return id
258}
259
260/** Reads the CLI's next piece of output into the run's events and outcomes. */
261async function pull(run: Run) {
262  const piece = await run.stream.next()
263  if (piece.done) {
264    const { code, signal } = piece.value
265    if (run.spec.cli === 'pi' && run.current.trim()) run.answer = run.current
266    if (run.spec.cli === 'opencode') run.answer = run.texts.get(run.lastMessage) ?? ''
267    run.ended = `exit ${code ?? signal}`
268    return
269  }
270  if (piece.value.stream === 'stderr') {
271    run.stderr = (run.stderr + piece.value.text).slice(-4000)
272    return
273  }
274  run.buffer += piece.value.text
275  const lines = run.buffer.split('\n')
276  run.buffer = lines.pop() ?? ''
277  for (const line of lines) {
278    let ev: any
279    try { ev = JSON.parse(line) } catch { continue }
280    const show = (text: string) => text && run.events.push({ kind: 'show', text })
281    if (run.spec.cli === 'pi') {
282      const d = ev.type === 'message_update' ? ev.assistantMessageEvent : undefined
283      if (ev.type === 'message_start' && ev.message?.role === 'assistant') run.current = ''
284      if (d?.type === 'text_delta') { run.current += d.delta ?? ''; show(d.delta ?? '') }
285      if (ev.type === 'tool_execution_start') called(run, ev.toolCallId, ev.toolName, ev.args)
286      if (ev.type === 'tool_execution_end') {
287        const id = run.ids.get(ev.toolCallId)
288        if (id) run.outcomes.set(id, { output: textOf(ev.result?.content), isError: ev.isError === true })
289      }
290      if (ev.type === 'turn_end') {
291        const m = ev.message ?? {}
292        if (m.provider && m.model) run.answered = `${m.provider}/${m.model}`
293        if (m.errorMessage) { run.error = m.errorMessage; show(`\n✗ ${m.errorMessage}\n`) }
294        if (run.current.trim()) run.answer = run.current
295        run.current = ''
296      }
297    } else {
298      const p = ev.part ?? {}
299      if (ev.type === 'tool_use') {
300        const id = called(run, p.callID ?? p.id ?? String(serial), p.tool ?? 'tool', p.state?.input ?? {})
301        const failed = p.state?.status === 'error'
302        run.outcomes.set(id, { output: String((failed ? p.state?.error : p.state?.output) ?? ''), isError: failed })
303      }
304      if (ev.type === 'text' && typeof p.text === 'string') {
305        run.lastMessage = p.messageID
306        run.texts.set(p.messageID, (run.texts.get(p.messageID) ?? '') + p.text)
307        show(`${p.text}\n`)
308      }
309      if (ev.type === 'error') { run.error = JSON.stringify(ev.error ?? ev).slice(0, 500); show(`✗ ${run.error}\n`) }
310    }
311  }
312}
313
314function stop(agentId: string) {
315  const run = runs.get(agentId)
316  runs.delete(agentId)
317  void run?.stream.return(undefined as never).catch(() => {})
318}
319
320/** Why a run ended with no answer, short: the error, else stderr's last lines. */
321function failureOf(run: Run) {
322  const stderr = run.stderr
323    .split('\n')
324    .filter((l) => l.trim() && !/^\[\d+\][+-]?\s+(Done|Exit|Terminated|Killed)/.test(l))
325    .slice(-6)
326    .join('\n')
327  return `ext-agent: ${run.spec.cli} (${run.spec.model ?? 'default model'}) ended without an answer (${run.ended}).` +
328    (run.error ? `\n${run.error}` : '') + (stderr ? `\n${stderr}` : '')
329}
330
331// ---------------------------------------------------------------------------
332
333export const register: Register = (on, options) => {
334  config = {
335    blockBuiltin: options.blockBuiltin === true,
336    defaultModel: String(options.defaultModel || 'pi'),
337    worktreeLayout: options.worktreeLayout === 'sibling' ? 'sibling' : 'claude',
338  }
339
340  if (config.blockBuiltin) on('agent.offer', ($, e, next) => (e.agent === TYPE || e.provider.plugin.split('@')[0] === 'ext-agent' ? next(e) : { isOffered: false }))
341
342  on('tool.describe', { tool: 'Agent' }, async ($, e, next) => {
343    const described = await next(e)
344    return {
345      ...described,
346      description:
347        `${described.description}\n\n` +
348        `ext-agent: subagent_type "${TYPE}" runs the task in pi or opencode instead of a Claude model. Its \`model\` takes ` +
349        `"pi[:<provider>/<model>[:<thinking>]]" or "opencode[:<provider/model[#variant]>]" (\`opencode models\` lists them); ` +
350        `Claude model names do nothing for it. The default is "${config.defaultModel}".` +
351        (config.blockBuiltin ? ' It is the only subagent_type allowed.' : ''),
352    }
353  })
354
355  // The model's Agent calls, rewritten before the engine parses them: the
356  // arguments of each Agent block are held until the block ends, then passed
357  // on as one piece with a pi / opencode `model` moved into the prompt.
358  on('turn.step', async function* ($, e, next) {
359    if (e.agentId !== undefined) return yield* workerStep($, e, next)
360    const held = new Map<number, string>()
361    const stream = next(e)
362    const flush = function* () {
363      for (const [index, json] of held) yield { kind: 'input', index, json: movedModel(json) } as TurnStepChunk
364      held.clear()
365    }
366    for await (const chunk of stream) {
367      if (chunk.kind === 'tool' && chunk.name === 'Agent') held.set(chunk.index, '')
368      else if (chunk.kind === 'input' && held.has(chunk.index)) {
369        held.set(chunk.index, held.get(chunk.index) + chunk.json)
370        continue
371      } else yield* flush()
372      yield chunk
373    }
374    yield* flush()
375    const result = await stream.result
376    return {
377      ...result,
378      toolUses: result.toolUses.map((use) =>
379        use.name === 'Agent' ? { ...use, input: JSON.parse(movedModel(JSON.stringify(use.input))) } : use,
380      ),
381    }
382  })
383
384  on('tool.call', async ($, e, next) => {
385    if (!ours.has(e.tool_use_id)) return next(e)
386    ours.delete(e.tool_use_id)
387    const run = e.agentId === undefined ? undefined : runs.get(e.agentId)
388    if (run === undefined) return { deny: 'ext-agent: the worker that made this call is gone.' }
389    const onAbort = () => stop(e.agentId as string)
390    next.signal.addEventListener('abort', onAbort)
391    try {
392      while (!run.outcomes.has(e.tool_use_id) && run.ended === undefined) await pull(run)
393    } finally {
394      next.signal.removeEventListener('abort', onAbort)
395    }
396    const outcome = run.outcomes.get(e.tool_use_id)
397    const call = run.calls.get(e.tool_use_id)
398    if (outcome === undefined || call === undefined) return { deny: `${run.spec.cli} ended before this call finished.` }
399    if (outcome.isError) return { deny: clip(outcome.output || 'failed') }
400    return { result: resultOf(call, outcome.output) as never }
401  })
402
403  // A worker's transcript mirrors every step of pi / opencode, so a long task
404  // outgrows the context window and the CLI ends the agent with "Prompt is too
405  // long". No model is asked to summarise it: the hook drops the middle itself.
406  on('session.compact', async ($, e, next) => {
407    if (e.agentId === undefined) return next(e)
408    const agent = (await $.agent.list()).find((a) => a.id === e.agentId)
409    if (agent === undefined || !isWorker(agent.type)) return next(e)
410    const m = e.messages
411    // The tail starts on an assistant message, so a tool_use keeps its result.
412    let cut = Math.max(m.length - KEEP_MESSAGES, 1)
413    while (cut < m.length && m[cut].role !== 'assistant') cut++
414    if (cut <= 1 || cut >= m.length) return { skip: 'ext-agent: the worker transcript is already short.' }
415    const isTask = (x: (typeof m)[number]) => x.role === 'user' && x.text !== '' && !x.toolResults?.length
416    const task = m.findLastIndex(isTask)
417    const dropped = m.slice(1, cut).filter((_, i) => i + 1 !== task)
418    const calls = dropped.reduce((n, x) => n + x.toolUses.length, 0)
419    const note = {
420      role: 'assistant' as const,
421      toolUses: [],
422      text: `ext-agent: ${dropped.length} earlier messages (${calls} tool calls) were dropped from this transcript to keep it within the context window. The pi / opencode session still holds the full history.`,
423    }
424    return { messages: [m[0], ...(task > 0 && task < cut ? [m[task]] : []), note, ...m.slice(cut)] }
425  })
426
427  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
428    const type = e.subagent_type ?? 'general-purpose'
429    if (!isWorker(type)) return config.blockBuiltin ? { deny: usage(`subagent_type "${type}" is not allowed.`) } : next(e)
430    const header = headerOf(e.prompt)
431    const spec = await specOf($, header.model)
432    if ('error' in spec) return { deny: usage(spec.error) }
433    if (e.isolation !== 'worktree') return next(e)
434
435    // The engine's worktree is the subagent's cwd, which pi / opencode never
436    // see; the worker makes its own from a `worktree:` line instead. Only the
437    // prompt changes here, so a call that passes through again is the same.
438    const lines = [
439      `model: ${header.model ?? config.defaultModel}`,
440      ...(header.cwd ? [`cwd: ${header.cwd}`] : []),
441      `worktree: ${header.worktree ?? e.name ?? e.description}`,
442      '',
443      header.body,
444    ]
445    return next({ ...e, isolation: undefined, prompt: lines.join('\n') })
446  })
447}
448
449/** Starts the CLI for a worker's turn, or says why it cannot. */
450async function started($: EngineInterface, agentId: string, name: string): Promise<Run | string> {
451  // The newest user message is the task: the spawn's prompt, or a SendMessage.
452  // Its model, cwd and worktree are the spawn's unless it names its own.
453  const messages = await $.session.messages({ agentId })
454  if ('deny' in messages) return `ext-agent: cannot read this agent's prompt (${messages.deny}).`
455  const headers = messages
456    .filter((m) => m.role === 'user')
457    .map((m) => m.text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim())
458    .filter(Boolean)
459    .map(headerOf)
460  const first = spawned.get(agentId) ?? headers[0] ?? { body: '' }
461  spawned.set(agentId, first)
462  const header = headers.at(-1) ?? { body: '' }
463  header.model ??= first.model
464  header.cwd ??= first.cwd
465  header.worktree ??= first.worktree
466  const spec = await specOf($, header.model)
467  if ('error' in spec) return `ext-agent: ${usage(spec.error)}`
468  if (!header.body) return 'ext-agent: the prompt is empty.'
469
470  let cwd = header.cwd ?? (await $.session.cwd())
471  if ((await $.process.run(['test', '-d', cwd])).exitCode !== 0) return `ext-agent: cwd "${cwd}" does not exist.`
472  if (header.worktree) {
473    const made = await worktreeOf($, cwd, header.worktree)
474    if ('error' in made) return `ext-agent: worktree "${header.worktree}" failed: ${made.error}`
475    cwd = made.path
476  }
477  const key = `ext-${name.replace(/[^A-Za-z0-9_-]/g, '-')}`
478  let argv: string[]
479  if (spec.cli === 'pi') {
480    argv = ['pi', '-p', '--mode', 'json', ...(spec.model ? ['--model', spec.model] : []), '--session-id', key, header.body]
481  } else {
482    const session = await opencodeSession($, cwd, key)
483    argv = ['opencode', 'run', '--standalone', '--auto', '--format', 'json', ...(spec.model ? ['--model', spec.model] : []), '--title', key,
484      ...(session ? ['--session', session] : []), header.body]
485  }
486  const run: Run = {
487    spec, stream: $.process.spawn({ argv: supervised(cwd, argv) }),
488    events: [{ kind: 'show', text: `${spec.cli} ${spec.model ?? '(default model)'} in ${cwd}\n` }],
489    calls: new Map(), outcomes: new Map(), ids: new Map(),
490    buffer: '', stderr: '', error: '', answered: spec.model ?? spec.cli, answer: '', current: '', texts: new Map(), lastMessage: '',
491  }
492  runs.set(agentId, run)
493  return run
494}
495
496/**
497 * A worker's step: the CLI's output up to its next tool call (the step ends
498 * calling the matching tool) or to its end (the step ends with its answer).
499 */
500const workerStep: StreamHook<'turn.step'> = async function* ($, e, next) {
501  if (e.agentId === undefined) return yield* next(e)
502  const agentId = e.agentId
503  const agent = (await $.agent.list()).find((a) => a.id === agentId)
504  if (agent === undefined || !isWorker(agent.type)) return yield* next(e)
505
506  const finish = async function* (answer: string) {
507    yield { kind: 'text', index: 1, text: answer } as TurnStepChunk
508    yield { kind: 'stop', stopReason: 'end_turn', usage: null } as TurnStepChunk
509    return { turnId: e.turnId, index: e.index, answer, toolUses: [], stopReason: 'end_turn' as const, usage: null }
510  }
511
512  let run = runs.get(agentId)
513  if (e.index === 0 || run === undefined) {
514    stop(agentId)
515    const made = await started($, agentId, agent.name ?? agent.id)
516    if (typeof made === 'string') return yield* finish(made)
517    run = made
518  }
519
520  const onAbort = () => stop(agentId)
521  next.signal.addEventListener('abort', onAbort)
522  try {
523    for (;;) {
524      const ev = run.events.shift()
525      if (ev === undefined) {
526        if (run.ended !== undefined) break
527        await pull(run)
528        continue
529      }
530      if (ev.kind === 'show') {
531        yield { kind: 'thinking', index: 0, text: ev.text } as TurnStepChunk
532        continue
533      }
534      yield { kind: 'tool', index: 1, id: ev.id, name: ev.name } as TurnStepChunk
535      yield { kind: 'input', index: 1, json: JSON.stringify(ev.input) } as TurnStepChunk
536      yield { kind: 'stop', stopReason: 'tool_use', usage: null } as TurnStepChunk
537      return {
538        turnId: e.turnId, index: e.index, answer: '',
539        toolUses: [{ name: ev.name, input: ev.input }], stopReason: 'tool_use' as const, usage: null,
540      }
541    }
542  } finally {
543    next.signal.removeEventListener('abort', onAbort)
544  }
545
546  runs.delete(agentId)
547  if (run.answer.trim()) return yield* finish(`${run.answer.trim()}\n\n— answered by ${run.spec.cli}, ${run.answered}`)
548  return yield* finish(failureOf(run))
549}
550