Everything for delegating coding work: Codex, agy and Claude worker skills; /delegate, /explore, /delegate-setup and /delegations; the official Codex plugin's…

A plugin for Claude Code: delegation.
Everything for delegating coding work: Codex, agy and Claude worker skills; /delegate, /explore, /delegate-setup and /delegations; the official Codex plugin's commands bundled as /codex-* with matching /agy-* commands (rescue, review, adversarial-review, status, result, cancel, setup); a measured delegation policy that refuses tasks too small to be worth it; and a live view of running jobs and what they cost.
Part of claude-mods, which lists this plugin and its siblings.
/plugin marketplace add MohamedHamed001/claude-delegation
/plugin install delegation@claude-delegation
Then start a new session: a session reads its plugins once, when it starts. Update later with /plugin marketplace update claude-delegation.
ps and pkill elsewhere. Developed on Windows; macOS has not been tested yet.npm install -g @openai/codex, then codex login.agy once.Everything for delegating coding work, in one plugin: worker skills, slash commands, a delegation policy that is enforced, and a live view of running jobs and what they cost.
Delegating feels cheaper than it is. A session carries a large context before any work, and every main-model request re-reads it; briefing a worker, waiting for it and reviewing its work add about as many requests as the delegation removes. A controlled test on real tasks of 1 to 6 files, each done three ways, found:
| Setup | Passed | Claude-side cost | Time |
|---|---|---|---|
| Opus alone | 3 of 3 | baseline | fastest |
| Opus + Sonnet subagent | 2 of 3 | about a third more | slower |
| Opus + Codex | 2 of 3 | about a tenth less | 2 to 3 times slower |
So the plugin's default is: do the work yourself, and delegate only when it pays.
| Part | What it does |
|---|---|
| Policy | Added to every session's system prompt, so nobody has to copy rules into CLAUDE.md. Delegate only: large separable work with a check the worker can run; work that should move off the Claude limit (Codex); when you ask; read-only review; fast read-only exploration (agy). |
| Enforcement | A delegation whose brief names only one or two files is refused, and Claude is told to do it directly. Allowed anyway when you asked for delegation, or when it is read-only. |
| Plain words | Saying "delegate this", "hand this over" or naming a worker (Codex, agy, Sonnet) counts as asking, no slash command needed. |
| Band | One line above the prompt while a job runs, when it finishes, or when it has run for 15+ minutes, with a Stop button. |
| Pane | Delegations button or /delegations: jobs running now, every job this session with what it cost, today's totals across sessions, and the cost test's findings. |
| Skills | codex-delegate, agy-delegate, claude-delegate, delegate-setup, gpt-5-4-prompting, codex-result-handling. |
| Command | What it does |
|---|---|
/delegate <task> | Delegates the task if it is worth it under the policy; otherwise says why and does it directly |
/explore <question> | Asks agy a fast, read-only question about the codebase; Claude spot-checks its file and line claims |
/delegate-setup | Chooses which worker and model handles which kind of work (writes the lane map) |
/delegations | Opens the pane |
And the same seven commands for each worker:
| Action | /codex-… | /agy-… |
|---|---|---|
rescue <task> | Hands a task to Codex (may edit the workspace) | Hands a task to agy (runs with permissions skipped, see below) |
review | Read-only review of your local changes | Read-only review of your local changes |
adversarial-review [focus] | Review that challenges the design and its assumptions | Same, on agy's stronger model |
status | Running and recent jobs | Running and recent jobs |
result [job-id] | A finished job's full report | A finished job's report |
cancel [job-id] | Stops a running job | Stops a running job |
setup | Checks Node, Codex and its sign-in | Checks agy and its sign-in, lists default models |
Flags on rescue and the reviews: --background (run detached; check with status), --model <name>, and for reviews --base <ref> (review a branch against a base instead of the working tree). /codex-setup --enable-review-gate turns on Codex's optional stop-time review gate (off by default).
status, result, cancel and setup answer at once without a Claude turn. Rescue and the reviews hand Claude a prompt: it writes the brief, runs the worker, then reviews the result itself.
--model on the command wins; otherwise the command's lane in your lane map; otherwise:
| Command | Lane | Default model |
|---|---|---|
/codex-rescue | implement | gpt-6-luna |
/codex-review | review | gpt-6-luna |
/codex-adversarial-review | deep-review | gpt-6.1-sol |
/agy-rescue | agy-implement | gemini-3.8-flash-high |
/agy-review | agy-review | gemini-3.8-flash-high |
/agy-adversarial-review | third-opinion | gemini-3.1-pro-high |
/explore | research | gemini-3.8-flash-medium |
Rule of thumb: the cheapest model that can do the job. Fast models for lookups, a mid model for edits and reviews, the strongest only for design challenges.
/codex-setup and /agy-setup to check them./delegate-setup once to pick your lanes. The lane map is stored in ~/.config/delegate-skills/config.json (or $XDG_CONFIG_HOME/delegate-skills/).Without a lane map the commands use the defaults above.
/agy-rescue therefore passes --dangerously-skip-permissions: agy can then do anything on your machine, not only in the repository. Running /agy-rescue is that approval. Reviews and /explore run in agy's read-only plan mode and are told not to run shell commands at all.~/.claude/delegation/agy-jobs/<id>/: job.json, brief.md, diff.patch (reviews), result.json and agy's logs.codex@openai-codex is installed as well, the bundled /codex-* commands step aside and point to its /codex:* ones; the policy and job tracking still apply to them.gpt-6.1-sol) has a small allowance. A Codex run that would use it (a Sol --model, the deep-review lane, or an adversarial review with no model given) first shows the model, the lane, and the brief's size and file count, with Run it and Cancel. Runs on other models start without a question.| Source | What it tells the plugin | ||
|---|---|---|---|
The Agent tool call and agent.spawn | A Claude subagent started, its id and its real model | ||
The subagent's own requests and turn.complete | What it cost and when it finished | ||
A shell command running <name>-delegate/scripts/relay.mjs --brief <file> | A relay job (Codex, agy, Claude CLI); a background one counts as finished when its process is gone | ||
| A shell command running `codex-companion.mjs task\ | review\ | adversarial-review` | A Codex plugin job; a background one is followed through the job id it prints |
| Main-conversation requests while a job is open | What delegating cost on the Opus side |
claude plugin validate .
claude plugin test .
To run your working copy instead of the installed version, add this folder to CLAUDE_CODE_PLUGIN_DIRS (separated by ; on Windows, : elsewhere), for example in the env block of ~/.claude/settings.json, then start a new session.
This repository is under the MIT licence.
It includes third-party code, each under its own licence, listed with every change in skills/NOTICE.md:
codex-delegate, agy-delegate, claude-delegate and delegate-setup from amElnagdy/delegate-skills (MIT).gpt-5-4-prompting and codex-result-handling from OpenAI's Codex plugin for Claude Code (codex@openai-codex 1.0.6), under the Apache License 2.0 with its NOTICE.Not affiliated with Anthropic, OpenAI or Google.
hooks/register.tsx 1006 lines1// Delegation: shows work handed to Claude subagents, Codex, agy and other relays.
2//
3// It also carries the delegation policy: the rule goes into every session's instructions,
4// and a delegation of a one- or two-file task is refused unless the user asked for it.
5//
6// What you see:
7// - A one-line band while a job runs (or just finished, ran too long, or vanished).
8// - A pane (Delegations button or /delegations): what is running, with a Stop button
9// for relay jobs; every job of this session with what it cost; today's totals; and
10// the findings of the 6 Oct cost test.
11//
12// Where the information comes from:
13// Claude subagent starts the Agent tool call (description, model, background)
14// its id and real model the agent.spawn event
15// its cost and its finish its own requests and its turn.complete, which carry its id
16// relay starts (Codex, ...) a shell command running <name>-delegate/scripts/relay.mjs
17// Codex plugin jobs a /codex:rescue subagent, or a codex-companion.mjs command;
18// a background one is watched through its job id
19// a foreground relay's finish the command returning, with the relay's summary
20// a background relay's finish the relay process disappearing from the process list
21// Opus's cost while delegating every main-conversation request while a job is open
22//
23// The rules (recognising a relay, sizing a task, choosing the band line) are in logic.ts.
24
25import { atom, read, update } from 'claude-code'
26import type { EngineInterface, Register } from 'claude-code'
27
28import type { DayTotals, Job } from '../types'
29import {
30 EMPTY_DAY,
31 addToDay,
32 bandFor,
33 costUnits,
34 dayOf,
35 formatElapsed,
36 formatUnits,
37 COMPANION_WORK,
38 DELEGATION_POLICY,
39 backgroundCompanionJobId,
40 filesNamed,
41 isCodexRescue,
42 isReadOnly,
43 isReadOnlyRequest,
44 isScarceRun,
45 isSmall,
46 killMatchingArgv,
47 laneMapText,
48 listProcessesArgv,
49 parseCompanion,
50 platformFromUname,
51 parseRelay,
52 refusalReason,
53 scarceQuestion,
54 userAskedToDelegate,
55 subagentWorker,
56 titleFromBrief,
57 touchedFiles,
58 ACTION_DESCRIPTIONS,
59 WORKER_ACTIONS,
60 agyJobState,
61 agyStatusTable,
62 fillTemplate,
63 modelFor,
64 newAgyJobId,
65 splitArgs,
66 withModel,
67} from './logic'
68import type { AgyJob, Lane, Platform, Usage, Worker, WorkerAction } from './logic'
69
70const PANE = 'delegations'
71const TICK_MS = 15_000
72const DAYS_KEY = 'days'
73const SMALL_TASK_ADVICE =
74 'Small task: in your cost test, Opus solo was cheaper and faster for 1-file changes'
75
76// The lane map from delegate-setup, read at session start: as lines for the system prompt,
77// and as data for the models the /codex-* and /agy-* commands pick.
78let laneText = ''
79let lanes: Record<string, Lane> = {}
80
81// True when the official Codex plugin is installed too: our /codex-* commands then point to
82// its /codex:* ones instead of running a second copy.
83let officialCodex = false
84
85// Where /agy-* jobs keep their files: one folder per job with job.json, brief.md, result.json.
86let agyJobsRoot = ''
87
88// Windows or macOS/Linux, decided once at session start; it picks the process commands.
89let platform: Platform = 'windows'
90
91// The user's latest message, typed by them. A small delegation is allowed when it asked
92// for one. A plain variable: losing it on a reload only means one extra refusal.
93let lastUserMessage = ''
94
95/** The refusal sent back for a small delegation the user did not ask for, or null to allow. */
96function smallDelegationRefusal(brief: string, readOnly: boolean): string | null {
97 if (readOnly || !isSmall(brief) || userAskedToDelegate(lastUserMessage)) {
98 return null
99 }
100
101 return refusalReason(filesNamed(brief))
102}
103
104const jobs = atom({ plugin: 'delegation', key: 'jobs' } as const, [])
105const today = atom({ plugin: 'delegation', key: 'today' } as const, EMPTY_DAY)
106const now = atom({ plugin: 'delegation', key: 'now' } as const, 0)
107
108/** Change one job, found by its id. */
109async function changeJob($: EngineInterface, id: string, change: (job: Job) => Job) {
110 await update($, jobs, list => list.map(job => (job.id === id ? change(job) : job)))
111}
112
113/** Add numbers to today's totals, here and in the store all sessions share. */
114async function addToday($: EngineInterface, change: Partial<DayTotals>) {
115 const day = dayOf(await $.clock.now())
116 const all = ((await $.store.get(DAYS_KEY)) ?? {}) as Record<string, DayTotals>
117 const updated = addToDay(all[day] ?? EMPTY_DAY, change)
118 // Keep only today's entry: older days are not shown anywhere.
119 await $.store.set(DAYS_KEY, { [day]: updated })
120 await update($, today, () => updated)
121}
122
123/** Record a new job and count it for today. */
124async function startJob($: EngineInterface, job: Job, isRelay: boolean) {
125 await update($, jobs, list => [...list, job])
126 await addToday($, { jobs: 1, relayCalls: isRelay ? 1 : 0 })
127 if (job.small) {
128 $.ui.toast(SMALL_TASK_ADVICE)
129 }
130}
131
132/** Mark a job ended. It keeps counting Opus requests until the current turn ends. */
133async function finishJob($: EngineInterface, id: string, status: Job['status'], filesChanged?: number | null) {
134 const at = await $.clock.now()
135 await changeJob($, id, job =>
136 job.status === 'running'
137 ? { ...job, status, finishedAt: at, filesChanged: filesChanged ?? job.filesChanged }
138 : job,
139 )
140 const job = (await read($, jobs)).find(one => one.id === id)
141 if (job && status === 'done') {
142 $.ui.toast(`${job.worker} finished: ${job.title}`)
143 }
144}
145
146/**
147 * The command lines of all running processes (PowerShell on Windows, ps elsewhere).
148 * Used to tell whether a background job is still alive.
149 */
150async function processCommandLines($: EngineInterface): Promise<string> {
151 const listed = await $.process.run(listProcessesArgv(platform), { timeoutMs: 15_000 })
152
153 return listed.stdout
154}
155
156/** A background job whose process is gone has finished (or died). Check them all. */
157async function checkBackgroundRelays($: EngineInterface) {
158 const open = (await read($, jobs)).filter(job => job.status === 'running' && job.background && job.aliveMarker)
159 if (open.length === 0) {
160 return
161 }
162 const running = await processCommandLines($)
163 for (const job of open) {
164 if (!running.includes(job.aliveMarker as string)) {
165 // We cannot tell success from failure here; the relay's result.json says that,
166 // and Claude reads it when it is notified. "done" means "no longer running".
167 await finishJob($, job.id, 'done')
168 }
169 }
170}
171
172/**
173 * Stop a job by ending every process whose command line carries its marker: the brief
174 * path of a relay job, or the job id of a background Codex plugin job.
175 */
176async function stopJob($: EngineInterface, job: Job) {
177 if (!job.aliveMarker) {
178 return
179 }
180 await $.process.run(killMatchingArgv(platform, job.aliveMarker), { timeoutMs: 20_000 }).catch(() => undefined)
181 await finishJob($, job.id, 'stopped')
182 $.ui.toast(`Stopped ${job.worker}: ${job.title}`)
183}
184
185/**
186 * A codex-companion.mjs call from the official Codex plugin.
187 *
188 * Inside a /codex:rescue subagent it is that rescue job's actual Codex run, already
189 * checked against the policy when the subagent started: only record its model and, for a
190 * background run, its job id. Called directly, it is a job of its own, checked here.
191 */
192async function runCompanion(
193 $: EngineInterface,
194 e: { tool_use_id: string; agentId?: string },
195 next: (e: never) => Promise<unknown>,
196 call: import('./logic').CompanionCall,
197) {
198 const rescueJob = e.agentId
199 ? (await read($, jobs)).find(job => job.agentId === e.agentId && job.status === 'running')
200 : undefined
201
202 if (!rescueJob) {
203 const readOnly = call.subcommand !== 'task' || !call.write
204 const refusal = smallDelegationRefusal(call.text, readOnly)
205 if (refusal) {
206 $.ui.toast('Refused a small delegation: doing it directly is cheaper')
207
208 return { deny: refusal }
209 }
210 const job: Job = {
211 id: e.tool_use_id,
212 worker: call.subcommand === 'task' ? 'Codex' : `Codex ${call.subcommand}`,
213 model: call.model,
214 title: call.text ? titleFromBrief(call.text) : call.subcommand,
215 startedAt: await $.clock.now(),
216 finishedAt: null,
217 status: 'running',
218 background: call.background,
219 small: isSmall(call.text),
220 aliveMarker: null,
221 agentId: null,
222 opusRequests: 0,
223 opusUnits: 0,
224 workerUnits: 0,
225 filesChanged: null,
226 isTracking: true,
227 }
228 await startJob($, job, true).catch(() => undefined)
229 } else if (call.model) {
230 await changeJob($, rescueJob.id, job => ({ ...job, model: call.model })).catch(() => undefined)
231 }
232
233 const ran = await next(e as never)
234 const ownerId = rescueJob ? rescueJob.id : e.tool_use_id
235 const jobId = call.background ? backgroundCompanionJobId(JSON.stringify(ran)) : null
236 if (jobId) {
237 // Codex keeps working in a worker process that carries this id: watch it, and let
238 // Stop end it.
239 await changeJob($, ownerId, job => ({ ...job, background: true, aliveMarker: jobId })).catch(() => undefined)
240 } else if (!rescueJob) {
241 await finishJob($, ownerId, 'done').catch(() => undefined)
242 }
243
244 return ran
245}
246
247/** Read a brief file for its title and size. A missing file just gives defaults. */
248async function readBrief($: EngineInterface, path: string): Promise<string> {
249 try {
250 const content = await $.fs.read(path)
251
252 return typeof content === 'string' ? content : ''
253 } catch {
254 return ''
255 }
256}
257
258const SCARCE_RUN = 'Run it'
259const SCARCE_CANCEL = 'Cancel'
260
261/**
262 * Before a Codex run on the scarce model: show what is about to be sent and wait for a yes.
263 * Returns the refusal text when the person cancels, or null to let the run start.
264 */
265async function scarceRefusal(
266 $: EngineInterface,
267 run: { model: string | null; lane: string | null; subcommand: string | null; brief: string },
268): Promise<string | null> {
269 if (!isScarceRun(run.model, run.lane, run.subcommand)) {
270 return null
271 }
272 let answer = SCARCE_CANCEL
273 try {
274 answer = await $.ui.ask(scarceQuestion(run), { header: 'Codex Sol', options: [SCARCE_RUN, SCARCE_CANCEL] })
275 } catch {
276 // Dismissed: treat it as Cancel, so the allowance is never spent by accident.
277 }
278 if (answer === SCARCE_RUN) {
279 return null
280 }
281
282 return (
283 "The user cancelled this Codex run in the delegation plugin's dialog" +
284 (answer !== SCARCE_CANCEL ? ` and wrote: "${answer}"` : '') +
285 '. Do not retry it; ask whether to narrow the brief or use a cheaper model (gpt-6-luna).'
286 )
287}
288
289// ---- The bundled worker commands: /codex-* and /agy-* ------------------------------------
290
291const PLAIN_REVIEW_STYLE =
292 'Ask agy for a careful code review of the patch: bugs, regressions, missing tests and unclear code.'
293const ADVERSARIAL_STYLE =
294 'Ask agy for an adversarial review: challenge whether this approach is the right one, which ' +
295 'assumptions it depends on and where it could fail under real conditions, not only line-level ' +
296 "defects. Include the user's focus, if any."
297
298/** The plugin's folder with forward slashes, so paths in prompts work in Bash and PowerShell. */
299function rootPath($: EngineInterface): string {
300 return $.plugin.root.replace(/\\/g, '/')
301}
302
303/** Hand Claude a prompt without waiting: it only starts once the command has finished. */
304function sendToClaude($: EngineInterface, text: string) {
305 void $.prompt.submit({ text, asUser: true }).catch(() => undefined)
306}
307
308/** A JSON file's content, or null when it is missing or not JSON. */
309async function readJson($: EngineInterface, path: string): Promise<any> {
310 try {
311 return JSON.parse(String(await $.fs.read(path)))
312 } catch {
313 return null
314 }
315}
316
317/**
318 * /codex-<action>: the vendored Codex companion. Status, result and cancel run it directly
319 * and answer at once; the others hand Claude the upstream command text, with our model added.
320 */
321async function codexCommand($: EngineInterface, action: WorkerAction, args: string): Promise<{ text: string }> {
322 if (officialCodex) {
323 return {
324 text: `The official Codex plugin is installed, so use /codex:${action}. This plugin still sizes and tracks its jobs.`,
325 }
326 }
327 const codexRoot = `${rootPath($)}/codex`
328 // setup only checks node, Codex and its sign-in, and prints how to install what is missing.
329 if (action === 'status' || action === 'result' || action === 'cancel' || action === 'setup') {
330 // CLAUDE_PLUGIN_DATA cleared: the companion then keeps state where Claude's shell runs do.
331 const ran = await $.process.run(
332 ['node', `${codexRoot}/scripts/codex-companion.mjs`, action, ...args.split(/\s+/).filter(Boolean)],
333 { env: { CLAUDE_PLUGIN_DATA: '' }, timeoutMs: 60_000 },
334 )
335
336 return { text: (ran.stdout || ran.stderr).trim() || `The Codex companion printed nothing for ${action}.` }
337 }
338
339 const model = modelFor(`codex-${action}`, splitArgs(args).model, lanes)
340 const template = String(await $.fs.read(`${codexRoot}/commands/${action}.md`))
341 lastUserMessage = `delegate ${args}` // the user asked: the size check must not refuse it
342 sendToClaude($, fillTemplate(template, { ARGUMENTS: withModel(args, model), CLAUDE_PLUGIN_ROOT: codexRoot }))
343
344 return { text: `Sent to Claude: Codex ${action} (${model}).` }
345}
346
347/** Every agy job on this machine, newest first, with its state. */
348async function agyJobs($: EngineInterface): Promise<Array<AgyJob & { state: ReturnType<typeof agyJobState> }>> {
349 const entries = await $.fs.list(agyJobsRoot).catch(() => [])
350 const running = entries.length > 0 ? await processCommandLines($).catch(() => '') : ''
351 const rows = []
352 for (const entry of entries.filter(one => one.kind === 'dir')) {
353 const job: AgyJob | null = await readJson($, `${agyJobsRoot}/${entry.name}/job.json`)
354 if (job) {
355 const result = await readJson($, `${agyJobsRoot}/${entry.name}/result.json`)
356 // The relay's command line carries the brief path, which carries the job id.
357 rows.push({ ...job, state: agyJobState(result, running.includes(job.id)) })
358 }
359 }
360
361 return rows.sort((a, b) => b.startedAt - a.startedAt)
362}
363
364/** /agy-<action>: the Codex commands' twins, built on the agy relay and a folder per job. */
365async function agyCommand($: EngineInterface, action: WorkerAction, args: string): Promise<{ text: string }> {
366 if (!agyJobsRoot) {
367 return { text: 'No home folder found, so agy jobs have nowhere to keep their files.' }
368 }
369 const at = await $.clock.now()
370
371 if (action === 'status') {
372 return { text: agyStatusTable((await agyJobs($)).slice(0, 10), at) }
373 }
374
375 if (action === 'result' || action === 'cancel') {
376 const all = await agyJobs($)
377 const wanted = args.trim()
378 const job = wanted
379 ? all.find(one => one.id === wanted)
380 : action === 'cancel'
381 ? all.find(one => one.state === 'running')
382 : all[0]
383 if (!job) {
384 return { text: wanted ? `No agy job ${wanted}. /agy-status lists them.` : `No agy job to ${action}.` }
385 }
386 if (action === 'cancel') {
387 if (job.state !== 'running') {
388 return { text: `${job.id} is not running (${job.state}).` }
389 }
390 await $.process.run(killMatchingArgv(platform, job.id), { timeoutMs: 20_000 }).catch(() => undefined)
391 for (const tracked of (await read($, jobs)).filter(one => one.status === 'running' && one.aliveMarker?.includes(job.id))) {
392 await finishJob($, tracked.id, 'stopped')
393 }
394
395 return { text: `Cancelled ${job.id}: ${job.title}` }
396 }
397 const result = await readJson($, `${agyJobsRoot}/${job.id}/result.json`)
398 if (!result) {
399 return { text: `${job.id} has no result yet (${job.state}).` }
400 }
401 // A review changes nothing: its git status is the changes it reviewed, not its own.
402 const files =
403 job.kind === 'rescue' && Array.isArray(result.touchedFiles)
404 ? `\n\nFiles touched: ${result.touchedFiles.join(', ') || 'none'}`
405 : ''
406 const failure = result.status !== 'completed' && result.error ? `\n\nWhy it failed: ${result.error}` : ''
407
408 return {
409 text: `${job.id} · ${job.kind} · ${result.status} · ${job.model}\n\n${result.finalMessage || '(agy gave no final message)'}${failure}${files}`,
410 }
411 }
412
413 if (action === 'setup') {
414 const version = await $.process.run(['agy', '--version'], { timeoutMs: 15_000 }).catch(() => null)
415 if (!version || version.exitCode !== 0) {
416 return {
417 text: 'agy is not installed or not on PATH. Install the Google Antigravity CLI, run `agy` once to sign in, then run /agy-setup again.',
418 }
419 }
420 const models = await $.process.run(['agy', 'models'], { timeoutMs: 30_000 }).catch(() => null)
421 const listed = models && models.exitCode === 0 ? models.stdout.trim().split('\n').filter(Boolean).length : 0
422
423 return {
424 text: [
425 `agy ${version.stdout.trim()}`,
426 listed > 0 ? `Signed in: ${listed} models available.` : 'Could not list models: run `agy` once to sign in.',
427 `Defaults: rescue ${modelFor('agy-rescue', null, lanes)}, review ${modelFor('agy-review', null, lanes)}, ` +
428 `adversarial review ${modelFor('agy-adversarial-review', null, lanes)}.`,
429 'Lanes in the delegate-setup config change these; --model on a command overrides them.',
430 ].join('\n'),
431 }
432 }
433
434 // rescue, review, adversarial-review: record the job, then hand Claude the steps.
435 const parsed = splitArgs(args)
436 const model = modelFor(`agy-${action}`, parsed.model, lanes)
437 const id = newAgyJobId(at)
438 const jobDir = `${agyJobsRoot}/${id}`
439 const job: AgyJob = { id, kind: action, title: parsed.text ? titleFromBrief(parsed.text) : action, model, startedAt: at }
440 await $.fs.write(`${jobDir}/job.json`, JSON.stringify(job, null, 2))
441
442 const template = String(await $.fs.read(`${rootPath($)}/agy/commands/${action === 'rescue' ? 'rescue' : 'review'}.md`))
443 lastUserMessage = `delegate ${args}` // the user asked: the size check must not refuse it
444 sendToClaude(
445 $,
446 fillTemplate(template, {
447 JOB_ID: id,
448 JOB_DIR: jobDir,
449 RELAY: `${rootPath($)}/skills/agy-delegate/scripts/relay.mjs`,
450 MODEL: model,
451 TASK: parsed.text || '(none given)',
452 BASE: parsed.base ?? 'none: review the working tree',
453 BACKGROUND_NOTE: parsed.background ? ' with `run_in_background: true`, then tell the user to check `/agy-status`' : '',
454 REVIEW_STYLE: action === 'adversarial-review' ? ADVERSARIAL_STYLE : PLAIN_REVIEW_STYLE,
455 }),
456 )
457
458 return { text: `Sent to Claude: agy ${action} (${model}), job ${id}.` }
459}
460
461export const register: Register = on => {
462 on('session.start', async ($, e, next) => {
463 // Which process commands to use. `uname` is missing on most Windows machines.
464 try {
465 const uname = await $.process.run(['uname', '-s'], { timeoutMs: 5_000 })
466 platform = platformFromUname(uname.exitCode === 0 ? uname.stdout : null)
467 } catch {
468 platform = 'windows'
469 }
470
471 try {
472 const at = await $.clock.now()
473 await update($, now, () => at)
474 const all = ((await $.store.get(DAYS_KEY)) ?? {}) as Record<string, DayTotals>
475 await update($, today, () => all[dayOf(at)] ?? EMPTY_DAY)
476 } catch {
477 // The band works without today's totals.
478 }
479
480 // The lane map lives where delegate-setup writes it: $XDG_CONFIG_HOME, else ~/.config.
481 try {
482 const xdg = await $.env.get('XDG_CONFIG_HOME')
483 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
484 const configDir = xdg ?? (home ? `${home}/.config` : null)
485 if (home) {
486 agyJobsRoot = `${home.replace(/\\/g, '/')}/.claude/delegation/agy-jobs`
487 }
488 if (configDir) {
489 const raw = await $.fs.read(`${configDir}/delegate-skills/config.json`)
490 const config = JSON.parse(typeof raw === 'string' ? raw : '{}')
491 laneText = laneMapText(config)
492 lanes = config.lanes ?? {}
493 }
494 } catch {
495 laneText = '' // no lanes yet: /delegate-setup creates them, and the commands use defaults
496 }
497
498 // The official Codex plugin names its commands codex:<name>.
499 try {
500 officialCodex = (await $.command.list()).some(command => command.name.startsWith('codex:'))
501 } catch {
502 officialCodex = false
503 }
504
505 // Each command on its own, so a name the app refuses does not block the others.
506 // (/delegate-setup is the shipped delegate-setup skill itself, so it needs no command.)
507 const commands = [
508 { name: 'delegations', description: 'Open the delegations pane' },
509 { name: 'delegate', description: 'Delegate a task, if it is worth it: /delegate <task>' },
510 { name: 'explore', description: 'Ask agy a fast read-only question about the codebase: /explore <question>' },
511 ]
512 for (const worker of ['codex', 'agy'] as const) {
513 const name = worker === 'codex' ? 'Codex' : 'agy'
514 for (const action of WORKER_ACTIONS) {
515 commands.push({ name: `${worker}-${action}`, description: ACTION_DESCRIPTIONS[action].replace('{worker}', name) })
516 }
517 }
518 for (const command of commands) {
519 await $.command.register(command).catch(() => undefined)
520 }
521
522 $.clock.every(TICK_MS, () => {
523 void (async () => {
524 const at = await $.clock.now()
525 await update($, now, () => at)
526 await checkBackgroundRelays($)
527 })().catch(() => undefined)
528 })
529
530 return next(e)
531 })
532
533 on('command.run', { command: 'delegate' }, async ($, e) => {
534 const task = e.args.trim()
535 if (task === '') {
536 return { text: 'Usage: /delegate <task>' }
537 }
538 lastUserMessage = `delegate ${task}` // the user asked: the size check must not refuse it
539 // Not awaited: the prompt only starts once this command has finished, so waiting for it
540 // here would wait forever and the command would never answer.
541 void $.prompt
542 .submit({
543 text:
544 'Delegate this task if it is worth it under the delegation policy; if it is not, say why ' +
545 'and do it yourself. Pick the worker and lane, write a tight brief (files it may change, ' +
546 `acceptance check), run it, then review the result.\n\nTask: ${task}`,
547 asUser: true,
548 })
549 .catch(() => undefined)
550
551 return { text: 'Sent to Claude to delegate.' }
552 })
553
554 on('command.run', { command: 'explore' }, async ($, e) => {
555 const question = e.args.trim()
556 if (question === '') {
557 return { text: 'Usage: /explore <question about the codebase>' }
558 }
559 lastUserMessage = `delegate explore ${question}`
560 // Not awaited, for the same reason as /delegate.
561 void $.prompt
562 .submit({
563 text:
564 'Answer this question about the codebase by asking agy read-only on its research lane ' +
565 '(agy-delegate skill, --lane research --read-only). Ask it for file and line references, ' +
566 `spot-check the key ones, then answer.\n\nQuestion: ${question}`,
567 asUser: true,
568 })
569 .catch(() => undefined)
570
571 return { text: 'Sent to Claude to ask agy.' }
572 })
573
574 on('command.run', { command: 'delegations' }, async $ => {
575 await $.ui.open({ id: PANE, title: 'Delegations' })
576
577 return { text: 'Delegations pane opened.' }
578 })
579
580 for (const worker of ['codex', 'agy'] as const satisfies readonly Worker[]) {
581 for (const action of WORKER_ACTIONS) {
582 on('command.run', { command: `${worker}-${action}` }, async ($, e) => {
583 try {
584 return worker === 'codex' ? await codexCommand($, action, e.args) : await agyCommand($, action, e.args)
585 } catch (error) {
586 return { text: `/${worker}-${action} failed: ${error instanceof Error ? error.message : String(error)}` }
587 }
588 })
589 }
590 }
591
592 // ---- The policy: in every session's instructions --------------------------------------
593
594 // Appended to the system prompt, so the rule travels with the plugin instead of living in
595 // each person's CLAUDE.md. "session" scope: it is ours, not shared across organizations.
596 on('prompt.compose', async ($, e, next) => {
597 const composed = await next(e)
598
599 return {
600 ...composed,
601 sections: [
602 ...composed.sections,
603 {
604 id: 'delegation:policy',
605 text: laneText ? `${DELEGATION_POLICY}\n\n${laneText}` : DELEGATION_POLICY,
606 scope: 'session' as const,
607 },
608 ],
609 }
610 })
611
612 on('prompt.submit', ($, e, next) => {
613 // Only what the person typed counts as asking; not text a plugin sent for them.
614 if (e.origin?.kind === 'composer') {
615 lastUserMessage = e.text
616 }
617
618 return next(e)
619 })
620
621 // ---- Claude subagents -------------------------------------------------------------
622
623 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
624 // Enforce the policy: a small task the user did not ask to delegate is refused, and
625 // Claude gets the reason back as the tool's result.
626 const rescue = isCodexRescue(e.subagent_type)
627 const readOnly =
628 isReadOnly({ subagentType: e.subagent_type }) || (rescue && isReadOnlyRequest(e.prompt))
629 const refusal = smallDelegationRefusal(e.prompt, readOnly)
630 if (refusal) {
631 $.ui.toast('Refused a small delegation: doing it directly is cheaper')
632
633 return { deny: refusal }
634 }
635
636 const background = e.run_in_background !== false
637 try {
638 const job: Job = {
639 id: e.tool_use_id,
640 // /codex:rescue: a Sonnet forwarder whose real worker is Codex.
641 worker: rescue ? 'Codex (rescue)' : subagentWorker(e.model),
642 model: rescue ? (e.prompt.match(/--model\s+(\S+)/)?.[1] ?? null) : (e.model ?? null),
643 title: e.description,
644 startedAt: await $.clock.now(),
645 finishedAt: null,
646 status: 'running',
647 background,
648 small: isSmall(e.prompt),
649 aliveMarker: null,
650 agentId: null,
651 opusRequests: 0,
652 opusUnits: 0,
653 workerUnits: 0,
654 filesChanged: null,
655 isTracking: true,
656 }
657 await startJob($, job, false)
658 } catch {
659 // A job we failed to record only goes missing from the pane.
660 }
661
662 const ran = await next(e)
663 // A foreground subagent has finished when the call returns. A background one only
664 // started; its own turn.complete (below) says when it ends. A rescue subagent that
665 // started Codex in the background has a job id marker by now: Codex is still working.
666 const job = (await read($, jobs).catch(() => [] as Job[])).find(one => one.id === e.tool_use_id)
667 if (!background && !(job && job.aliveMarker)) {
668 await finishJob($, e.tool_use_id, 'done').catch(() => undefined)
669 }
670
671 return ran
672 })
673
674 // The spawn event links the tool call to the subagent's id and its real model.
675 on('agent.spawn', async ($, e, next) => {
676 const spawned = await next(e)
677 if (spawned.agentId) {
678 const agentId = spawned.agentId
679 await changeJob($, e.tool_use_id, job => ({
680 ...job,
681 agentId,
682 model: spawned.model ?? job.model,
683 worker: job.worker === 'Subagent' ? subagentWorker(spawned.model) : job.worker,
684 })).catch(() => undefined)
685 }
686
687 return spawned
688 })
689
690 // ---- Relays: Codex, agy, a separate Claude CLI ---------------------------------------
691
692 for (const shell of ['Bash', 'PowerShell'] as const) {
693 on('tool.call', { tool: shell }, async ($, e, next) => {
694 const companion = parseCompanion(e.command)
695 if (companion && COMPANION_WORK.has(companion.subcommand)) {
696 const cancelled = await scarceRefusal($, {
697 model: companion.model,
698 lane: null,
699 subcommand: companion.subcommand,
700 brief: companion.text,
701 })
702 if (cancelled) {
703 return { deny: cancelled }
704 }
705
706 return runCompanion($, e, next, companion)
707 }
708
709 const relay = parseRelay(e.command)
710 if (!relay) {
711 return next(e)
712 }
713
714 const brief = await readBrief($, relay.briefPath)
715 const refusal = smallDelegationRefusal(brief, isReadOnly({ command: e.command }))
716 if (refusal) {
717 $.ui.toast('Refused a small delegation: doing it directly is cheaper')
718
719 return { deny: refusal }
720 }
721 if (relay.worker === 'Codex') {
722 const cancelled = await scarceRefusal($, { model: relay.model, lane: relay.lane, subcommand: null, brief })
723 if (cancelled) {
724 return { deny: cancelled }
725 }
726 }
727
728 const background = e.run_in_background === true
729 try {
730 const job: Job = {
731 id: e.tool_use_id,
732 worker: relay.worker,
733 model: relay.model ?? (relay.lane ? `lane ${relay.lane}` : null),
734 title: brief ? titleFromBrief(brief) : 'delegated task',
735 startedAt: await $.clock.now(),
736 finishedAt: null,
737 status: 'running',
738 background,
739 small: isSmall(brief),
740 aliveMarker: relay.briefPath,
741 agentId: null,
742 opusRequests: 0,
743 opusUnits: 0,
744 workerUnits: 0,
745 filesChanged: null,
746 isTracking: true,
747 }
748 await startJob($, job, true)
749 } catch {
750 // As above.
751 }
752
753 const ran = await next(e)
754 if (!background) {
755 // The relay ran in the foreground and has finished; its summary is the output.
756 const output = JSON.stringify(ran)
757 const failed = /"status"\s*:\s*\\?"(failed|timeout|aborted)/.test(output)
758 await finishJob($, e.tool_use_id, failed ? 'failed' : 'done', touchedFiles(output.replace(/\\"/g, '"'))).catch(
759 () => undefined,
760 )
761 }
762
763 return ran
764 })
765 }
766
767 // ---- Cost: every model request ------------------------------------------------------
768
769 on('turn.step', async function* ($, e, next) {
770 const result = yield* next(e)
771 try {
772 if (result.usage) {
773 const units = costUnits(result.usage as Usage)
774 if (e.agentId) {
775 // A subagent's request: it belongs to the job with that subagent id.
776 const agentId = e.agentId
777 const owner = (await read($, jobs)).find(job => job.agentId === agentId)
778 if (owner) {
779 await changeJob($, owner.id, job => ({ ...job, workerUnits: job.workerUnits + units }))
780 await addToday($, { workerUnits: units })
781 }
782 } else {
783 // An Opus request while delegation is open counts towards what delegating cost.
784 const open = (await read($, jobs)).filter(job => job.isTracking)
785 if (open.length > 0) {
786 await update($, jobs, list =>
787 list.map(job =>
788 job.isTracking
789 ? { ...job, opusRequests: job.opusRequests + 1, opusUnits: job.opusUnits + units }
790 : job,
791 ),
792 )
793 await addToday($, { opusRequests: 1, opusUnits: units })
794 }
795 }
796 }
797 } catch {
798 // Never let bookkeeping break a model request.
799 }
800
801 return result
802 })
803
804 on('turn.complete', async ($, e, next) => {
805 const result = await next(e)
806 try {
807 if (e.agentId) {
808 // A background subagent finished.
809 const agentId = e.agentId
810 const owner = (await read($, jobs)).find(job => job.agentId === agentId && job.status === 'running')
811 if (owner) {
812 await finishJob($, owner.id, e.isAborted ? 'failed' : 'done')
813 }
814 } else {
815 // The main turn ended: jobs that finished stop counting Opus requests now,
816 // which covers the review Opus did right after the result came back.
817 await update($, jobs, list =>
818 list.map(job => (job.isTracking && job.status !== 'running' ? { ...job, isTracking: false } : job)),
819 )
820 }
821 } catch {
822 // As above.
823 }
824
825 return result
826 })
827
828 // ---- Drawing --------------------------------------------------------------------------
829
830 // The band: one line in the shared three-column layout, above what others draw.
831 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
832 const beneath = await next(e)
833 if (e.props.hasSurvey) {
834 return beneath
835 }
836 const time = await read($, now)
837 const band = bandFor(await read($, jobs), time)
838 if (band.kind === 'hidden') {
839 return beneath
840 }
841
842 const { Box, Button, Text } = $.ui.resolve(e)
843 const job = band.job
844 const who = `${job.worker}${job.model ? ` (${job.model})` : ''}`
845
846 return (
847 <Box flexDirection="column" rowGap={1}>
848 {/* Its own filled card, one per plugin, so stacked bands read as separate. */}
849 <Box columnGap={1} alignItems="center" backgroundColor="userMessageBackground" paddingX={1}>
850 <Box width={2}>
851 {band.kind === 'running' && <Text color="warning">⠹</Text>}
852 {band.kind === 'advice' && <Text color="warning">i</Text>}
853 {band.kind === 'long' && <Text color="warning">!</Text>}
854 {band.kind === 'finished' && (
855 <Text color={job.status === 'done' ? 'success' : 'error'}>{job.status === 'done' ? '✓' : '✗'}</Text>
856 )}
857 </Box>
858
859 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
860 {band.kind === 'running' && (
861 <Text wrap="truncate-end">
862 {who} · {job.title} · {formatElapsed(time - job.startedAt)}
863 {band.count > 1 ? ` · ${band.count} running` : ''}
864 </Text>
865 )}
866 {band.kind === 'advice' && (
867 <Text color="warning" wrap="truncate-end">
868 {SMALL_TASK_ADVICE}
869 </Text>
870 )}
871 {band.kind === 'long' && (
872 <Text color="warning" wrap="truncate-end">
873 {who} running for {formatElapsed(time - job.startedAt)}. Still working?
874 </Text>
875 )}
876 {band.kind === 'finished' && (
877 <Text wrap="truncate-end">
878 {job.worker} {job.status} in {formatElapsed((job.finishedAt ?? time) - job.startedAt)}
879 {job.filesChanged !== null ? ` · ${job.filesChanged} files changed` : ''} · {job.title}
880 </Text>
881 )}
882 </Box>
883
884 {band.kind !== 'finished' && job.aliveMarker && job.status === 'running' && (
885 <Button key="band-stop" label="Stop" onPress={() => stopJob($, job)} />
886 )}
887 <Button
888 key="open-delegations"
889 label="Delegations"
890 onPress={() => $.ui.open({ id: PANE, title: 'Delegations' })}
891 />
892 </Box>
893 {beneath}
894 </Box>
895 )
896 })
897
898 // The pane: running now, this session, today, and the cost test's findings.
899 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
900 const list = await read($, jobs)
901 const day = await read($, today)
902 const time = await read($, now)
903 const { Box, Button, Text } = $.ui.resolve(e)
904 const running = list.filter(job => job.status === 'running')
905
906 /** One outlined card with a muted heading, as in the focus pane. */
907 const section = (title: string, body: unknown) => (
908 <Box flexDirection="column" rowGap={1} paddingX={1} borderStyle="round" borderDimColor>
909 <Text dimColor>{title}</Text>
910 {body}
911 </Box>
912 )
913
914 /** A label on the left and its value on the right. */
915 const row = (label: string, value: string) => (
916 <Box columnGap={2}>
917 <Box flexGrow={1}>
918 <Text dimColor>{label}</Text>
919 </Box>
920 <Text>{value}</Text>
921 </Box>
922 )
923
924 const statusMark: Record<Job['status'], string> = {
925 running: '⠹',
926 done: '✓',
927 failed: '✗',
928 stopped: '■',
929 }
930
931 return (
932 <Box flexDirection="column" rowGap={1} padding={1}>
933 {section(
934 `Running now (${running.length})`,
935 <Box flexDirection="column" rowGap={1}>
936 {running.length === 0 && <Text dimColor>Nothing delegated right now</Text>}
937 {running.map(job => (
938 <Box columnGap={1} alignItems="center">
939 <Text color="warning">⠹</Text>
940 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
941 <Text>
942 {job.worker}
943 {job.model ? ` (${job.model})` : ''} · {job.title}
944 </Text>
945 </Box>
946 <Text dimColor>{formatElapsed(time - job.startedAt)}</Text>
947 {job.aliveMarker && (
948 <Button key={`stop:${job.id}`} label="Stop" onPress={() => stopJob($, job)} />
949 )}
950 </Box>
951 ))}
952 </Box>,
953 )}
954
955 {section(
956 `This session (${list.length})`,
957 <Box flexDirection="column" rowGap={1}>
958 {list.length === 0 && <Text dimColor>No delegations yet in this session</Text>}
959 {list.map(job => (
960 <Box flexDirection="column">
961 <Box columnGap={1}>
962 <Text color={job.status === 'done' ? 'success' : job.status === 'running' ? 'warning' : 'error'}>
963 {statusMark[job.status]}
964 </Text>
965 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
966 <Text>
967 {job.worker} · {job.title}
968 </Text>
969 </Box>
970 {job.small && <Text dimColor>small</Text>}
971 </Box>
972 <Text dimColor>
973 {' '}Opus {job.opusRequests} requests, {formatUnits(job.opusUnits)} units
974 {job.workerUnits > 0 ? ` · ${job.worker} ${formatUnits(job.workerUnits)} units` : ''}
975 {job.filesChanged !== null ? ` · ${job.filesChanged} files` : ''}
976 {job.finishedAt !== null ? ` · ${formatElapsed(job.finishedAt - job.startedAt)}` : ''}
977 </Text>
978 </Box>
979 ))}
980 </Box>,
981 )}
982
983 {section(
984 'Today, all sessions',
985 <Box flexDirection="column">
986 {row('Delegations', String(day.jobs))}
987 {row('Opus requests while delegating', String(day.opusRequests))}
988 {row('Opus units while delegating', formatUnits(day.opusUnits))}
989 {row('Claude worker units', formatUnits(day.workerUnits))}
990 {row('Relay calls (Codex and others)', String(day.relayCalls))}
991 </Box>,
992 )}
993
994 {section(
995 'From your cost test (6 Oct)',
996 <Box flexDirection="column">
997 <Text>Opus solo: passed 3 of 3, fastest</Text>
998 <Text>Codex: about 10% cheaper on Claude, 2 to 3 times slower</Text>
999 <Text>Sonnet: most expensive every time</Text>
1000 </Box>,
1001 )}
1002 </Box>
1003 )
1004 })
1005}
1006hooks/logic.ts 594 lines1// The delegation mod's rules, as plain functions with no access to the app.
2// Tested on their own in logic.test.ts; register.tsx only does the wiring.
3
4import type { DayTotals, Job } from '../types'
5
6const SECOND = 1000
7const MINUTE = 60 * SECOND
8
9/** A running job past this age gets a warning: the Codex stall in the cost test hung for hours. */
10export const LONG_RUNNING_MS = 15 * MINUTE
11/** How long the "small task" advice stays in the band after a delegation starts. */
12export const ADVICE_MS = 60 * SECOND
13/** How long a finished job stays in the band. */
14export const FINISHED_SHOWN_MS = 10 * MINUTE
15
16/** The token counts the API reports for one request. */
17export type Usage = {
18 input_tokens: number
19 output_tokens: number
20 cache_read_input_tokens: number
21 cache_creation_input_tokens: number
22}
23
24/**
25 * One request in cost units: the API price list's ratios between token kinds
26 * (fresh input 1, cache read 0.1, cache write 2, output 5). Same rule as the cache mod.
27 */
28export function costUnits(usage: Usage): number {
29 return (
30 usage.input_tokens +
31 usage.cache_read_input_tokens * 0.1 +
32 usage.cache_creation_input_tokens * 2 +
33 usage.output_tokens * 5
34 )
35}
36
37/** A relay command that hands work to another tool, as found in a shell command. */
38export type RelayCall = {
39 worker: string
40 briefPath: string
41 model: string | null
42 lane: string | null
43}
44
45const RELAY_WORKERS: Record<string, string> = {
46 'codex-delegate': 'Codex',
47 'agy-delegate': 'agy',
48 'claude-delegate': 'Claude CLI',
49 'cursor-delegate': 'Cursor',
50 'opencode-delegate': 'opencode',
51}
52
53/** Read one flag's value: `--model gpt-6-luna` or `--model "gpt 6"` -> the value. */
54function flag(command: string, name: string): string | null {
55 const match = command.match(new RegExp(`--${name}\\s+(?:"([^"]+)"|'([^']+)'|(\\S+))`))
56
57 return match ? (match[1] ?? match[2] ?? match[3]) : null
58}
59
60/**
61 * The part of a shell command that is actually run. A heredoc (`cat > file <<'EOF' ...`)
62 * carries a file's text inside the command; that text can mention a relay or the companion
63 * without calling it, so everything from the first heredoc marker on is left out.
64 */
65export function runPart(command: string): string {
66 return command.split(/<<-?\s*['"]?\w+/)[0]
67}
68
69/**
70 * Recognise a delegation in a shell command: a `relay.mjs` call with a brief.
71 * Returns null for anything else, including `relay.mjs --help`.
72 */
73export function parseRelay(fullCommand: string): RelayCall | null {
74 const command = runPart(fullCommand)
75 const relay = command.match(/([\w-]+-delegate)[\\/]scripts[\\/]relay\.mjs/)
76 if (!relay) {
77 return null
78 }
79 const briefPath = flag(command, 'brief')
80 if (!briefPath) {
81 return null // --help, or a brief piped on stdin, which we cannot read back
82 }
83
84 return {
85 worker: RELAY_WORKERS[relay[1]] ?? relay[1],
86 briefPath,
87 model: flag(command, 'model'),
88 lane: flag(command, 'lane'),
89 }
90}
91
92/** The worker name for a Claude subagent, from the model it was asked to use. */
93export function subagentWorker(model: string | undefined): string {
94 if (!model) {
95 return 'Subagent'
96 }
97 const family = model.replace(/^claude-/, '').split('-')[0]
98
99 return family.charAt(0).toUpperCase() + family.slice(1)
100}
101
102/**
103 * How many distinct files a brief names, counted from path-like words with a code
104 * extension. A rough measure of task size: good enough to flag a one-file job.
105 */
106export function filesNamed(text: string): number {
107 const paths = text.match(
108 /[\w.\\/-]+\.(?:py|ts|tsx|js|jsx|mjs|cjs|json|sql|ya?ml|toml|cs|java|go|rs|html|css|scss|vue|kt|swift|rb|php)\b/g,
109 )
110
111 return new Set(paths ?? []).size
112}
113
114/** One or two named files: the size where Opus solo was cheaper and faster in the cost test. */
115export function isSmall(text: string): boolean {
116 const count = filesNamed(text)
117
118 return count > 0 && count <= 2
119}
120
121/** Files the relay says the worker changed: `"touchedFiles": ["a", "b"]` -> 2. */
122export function touchedFiles(output: string): number | null {
123 const match = output.match(/"touchedFiles"\s*:\s*(\[[^\]]*\]|null)/)
124 if (!match || match[1] === 'null') {
125 return null
126 }
127
128 return (match[1].match(/"[^"]*"/g) ?? []).length
129}
130
131/** A short task name from a brief: its first non-empty line, without markdown, cut to fit. */
132export function titleFromBrief(brief: string): string {
133 const line =
134 brief
135 .split('\n')
136 .map(text => text.replace(/^[#>*\-\s]+/, '').replace(/[*_`]/g, '').trim())
137 .find(text => text !== '') ?? 'delegated task'
138
139 return line.length <= 60 ? line : `${line.slice(0, 59).trimEnd()}…`
140}
141
142/** 372_000 -> "45s"-style elapsed time: "45s", "6m 12s", "1h 3m". */
143export function formatElapsed(ms: number): string {
144 const seconds = Math.max(0, Math.floor(ms / SECOND))
145 if (seconds < 60) {
146 return `${seconds}s`
147 }
148 const minutes = Math.floor(seconds / 60)
149 if (minutes < 60) {
150 return `${minutes}m ${seconds % 60}s`
151 }
152
153 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
154}
155
156/** 186_432 -> "186k". */
157export function formatUnits(units: number): string {
158 if (units >= 1_000_000) {
159 return `${(units / 1_000_000).toFixed(1)}M`
160 }
161
162 return units >= 1_000 ? `${Math.round(units / 1_000)}k` : String(Math.round(units))
163}
164
165/** The calendar day of a moment, in local time: "2026-10-06". */
166export function dayOf(ms: number): string {
167 const date = new Date(ms)
168 const pad = (n: number) => String(n).padStart(2, '0')
169
170 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
171}
172
173export const EMPTY_DAY: DayTotals = { jobs: 0, opusRequests: 0, opusUnits: 0, workerUnits: 0, relayCalls: 0 }
174
175/** Add one set of numbers to a day's totals. */
176export function addToDay(day: DayTotals, change: Partial<DayTotals>): DayTotals {
177 return {
178 jobs: day.jobs + (change.jobs ?? 0),
179 opusRequests: day.opusRequests + (change.opusRequests ?? 0),
180 opusUnits: day.opusUnits + (change.opusUnits ?? 0),
181 workerUnits: day.workerUnits + (change.workerUnits ?? 0),
182 relayCalls: day.relayCalls + (change.relayCalls ?? 0),
183 }
184}
185
186/** What the band shows. */
187export type Band =
188 | { kind: 'hidden' }
189 | { kind: 'advice'; job: Job }
190 | { kind: 'running'; job: Job; count: number }
191 | { kind: 'long'; job: Job }
192 | { kind: 'finished'; job: Job }
193
194/**
195 * Pick the one thing worth a line, most urgent first:
196 * long a job has been running for 15 minutes or more
197 * advice a small job started less than a minute ago
198 * running a job is in progress
199 * finished a job ended in the last 10 minutes
200 */
201export function bandFor(jobs: readonly Job[], now: number): Band {
202 const running = jobs.filter(job => job.status === 'running')
203 const newest = running.at(-1)
204 if (newest) {
205 const oldest = running[0]
206 if (now - oldest.startedAt >= LONG_RUNNING_MS) {
207 return { kind: 'long', job: oldest }
208 }
209 if (newest.small && now - newest.startedAt < ADVICE_MS) {
210 return { kind: 'advice', job: newest }
211 }
212
213 return { kind: 'running', job: newest, count: running.length }
214 }
215
216 const finished = jobs
217 .filter(job => job.finishedAt !== null && now - job.finishedAt < FINISHED_SHOWN_MS)
218 .at(-1)
219
220 return finished ? { kind: 'finished', job: finished } : { kind: 'hidden' }
221}
222
223// ---------------------------------------------------------------------------
224// The delegation policy this plugin carries, so nobody has to copy it into CLAUDE.md.
225// ---------------------------------------------------------------------------
226
227/** Added to every session's system prompt by the prompt.compose hook. */
228export const DELEGATION_POLICY = `# Delegation policy (from the delegation plugin)
229
230Delegate only where it pays. Measured on real tasks of 1 to 6 files, each done three ways:
231Opus alone passed every task and was fastest; Opus with a Sonnet subagent cost about a
232third more on the Claude side and failed once; Opus with Codex cost about a tenth less on
233the Claude side but took two to three times as long and failed once.
234
235Why: a session carries a large context before any work, and every main-model request
236re-reads it. Delegating saves only when it removes main-model requests; briefing, waiting
237and reviewing usually add about as many as they remove.
238
239Default: do the work yourself. Delegate only when at least one holds:
2401. The work is large and separable (many files, or a long run-and-fix loop) and has an
241 acceptance check the worker can run itself.
2422. The user's Claude limit is the constraint and time is not: use Codex.
2433. The user explicitly asks for delegation.
2444. It is an independent, read-only review or second opinion.
2455. It is fast, read-only exploration (sweeping the codebase, "where is X", "how does Y work")
246 that would otherwise read many files into your own context: ask agy on its research lane
247 and spot-check the key file and line claims, since fast models are less careful.
248
249Do not delegate changes to one or two files, quick fixes, or work you will have to re-read
250in full to review. Do not hand implementation to Sonnet subagents.
251
252When you do delegate: write one tight brief with the acceptance check and the exact files
253the worker may change (workers have broken shared test fixtures); run Codex relays in the
254foreground when nobody will resume the session; keep the review to reading the diff and
255running the tests.
256
257When the user says "delegate", "hand this over", "hand it off" or names a worker (Codex,
258agy, Sonnet), treat it as a request to delegate, even without a slash command.
259
260This plugin ships the skills codex-delegate, agy-delegate, claude-delegate, delegate-setup,
261gpt-5-4-prompting and codex-result-handling; the commands /delegate, /explore, /delegate-setup
262and /delegations; and for Codex and for agy the same seven commands: rescue (hand over a task),
263review, adversarial-review, status, result, cancel and setup (/codex-review, /agy-rescue, ...).
264It refuses a delegation whose brief names only one or two files, unless the user asked for
265delegation or the delegation is read-only.`
266
267/** Does the user's own message ask for delegation? Then a small delegation is allowed. */
268export function userAskedToDelegate(message: string): boolean {
269 return (
270 /\b(delegat\w*|codex|sonnet|haiku|subagents?|sub-agents?|agy|antigravity|luna|terra|sol)\b/i.test(message) ||
271 /\bhand(?:ing|ed)?[\s-]*(?:(?:it|this|that|them)\s+)?(?:over|off)\b|\bhandover\b/i.test(message)
272 )
273}
274
275/** Read-only work (exploring, reviewing, second opinions) is never blocked. */
276export function isReadOnly(options: { subagentType?: string; command?: string }): boolean {
277 if (options.subagentType && /^(explore|plan|claude-code-guide)$/i.test(options.subagentType)) {
278 return true
279 }
280 const command = options.command ?? ''
281
282 return /--read-only\b|--sandbox\s+read-only|--lane\s+(review|deep-review|explore|second-opinion|third-opinion|research)\b/.test(
283 command,
284 )
285}
286
287/** The reason given back to Claude when a small delegation is refused. */
288export function refusalReason(files: number): string {
289 return (
290 `Delegation refused by the delegation plugin: the brief names only ${files} file(s). ` +
291 'For changes this small, doing the work yourself was cheaper and faster in measured runs. ' +
292 'Do it directly. (Delegation is allowed when the user asks for it, or for read-only work.)'
293 )
294}
295
296// ---------------------------------------------------------------------------
297// The official Codex plugin (openai-codex): /codex:rescue and codex-companion.mjs.
298//
299// /codex:rescue starts a "codex:codex-rescue" subagent, a thin forwarder that runs one
300// shell command: `node .../codex-companion.mjs task [--background] [--write] ... "<task>"`.
301// The companion can also be called directly (review, adversarial-review). A background
302// task prints "<title> started in the background as <job-id>.", and its worker process
303// carries `--job-id <job-id>` on its command line.
304// ---------------------------------------------------------------------------
305
306/** One codex-companion.mjs command. */
307export type CompanionCall = {
308 subcommand: string
309 background: boolean
310 /** --write: Codex may edit files. Without it the companion runs Codex read-only. */
311 write: boolean
312 model: string | null
313 /** The task text: the last long quoted argument. */
314 text: string
315}
316
317/** Companion subcommands that do work; status, result, cancel and setup are bookkeeping. */
318export const COMPANION_WORK = new Set(['task', 'review', 'adversarial-review'])
319
320/** The subagent type /codex:rescue uses. */
321export function isCodexRescue(subagentType: string | undefined): boolean {
322 return subagentType === 'codex:codex-rescue'
323}
324
325/** The last quoted argument long enough to be a task description. */
326function lastQuoted(command: string): string {
327 const quoted = [...command.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)]
328 .map(match => match[1] ?? match[2])
329 .filter(text => text.length > 15 && !/codex-companion\.mjs/.test(text))
330
331 return quoted.at(-1) ?? ''
332}
333
334/** Recognise a codex-companion.mjs call in a shell command, or null. */
335export function parseCompanion(fullCommand: string): CompanionCall | null {
336 const command = runPart(fullCommand)
337 const match = command.match(/codex-companion\.mjs["']?\s+([a-z-]+)/)
338 if (!match) {
339 return null
340 }
341
342 return {
343 subcommand: match[1],
344 background: /--background\b/.test(command),
345 write: /--write\b/.test(command),
346 model: flag(command, 'model'),
347 text: lastQuoted(command),
348 }
349}
350
351// ---- Scarce runs: ask before spending the Sol allowance ----------------------------------
352//
353// Sol (gpt-6.1-sol) is the strongest Codex model and its allowance is small: one large
354// adversarial review can use most of it. So a run that would use Sol first shows what is
355// about to be sent and waits for a yes. Cheaper models (Luna) run without a question.
356
357/**
358 * Would this Codex run use Sol? True when the model is named as Sol, when the lane is
359 * `deep-review` (mapped to Sol), or for an adversarial review with no model given (Sol is
360 * its default).
361 */
362export function isScarceRun(model: string | null, lane: string | null, subcommand: string | null): boolean {
363 if (model) {
364 return /sol/i.test(model)
365 }
366
367 return lane === 'deep-review' || subcommand === 'adversarial-review'
368}
369
370/** The question shown before a scarce run: model, lane, and how much is being sent. */
371export function scarceQuestion(run: { model: string | null; lane: string | null; brief: string }): string {
372 const model = run.model ?? 'gpt-6.1-sol (the default here)'
373 const lane = run.lane ? `, lane ${run.lane}` : ''
374 const files = filesNamed(run.brief)
375 const size = run.brief
376 ? `The brief is ${run.brief.length.toLocaleString('en-US')} characters and names ${files} file${files === 1 ? '' : 's'}.`
377 : 'It reviews the current changes; no brief text.'
378
379 return `Claude is about to start a Codex run on ${model}${lane}, the scarce model. ${size} Run it?`
380}
381
382/** The job id a background companion task reports, or null. */
383export function backgroundCompanionJobId(output: string): string | null {
384 return output.match(/started in the background as ([\w.-]+?)\.(?:\s|$|\\n|")/)?.[1] ?? null
385}
386
387/** A rescue request that only asks for diagnosis, review or research runs Codex read-only. */
388export function isReadOnlyRequest(text: string): boolean {
389 return (
390 /\b(read-only|review|diagnos\w*|investigat\w*|research)\b/i.test(text) &&
391 !/\b(fix|implement|change|write)\b/i.test(text)
392 )
393}
394
395// ---------------------------------------------------------------------------
396// Processes, on Windows and on macOS/Linux.
397// ---------------------------------------------------------------------------
398
399export type Platform = 'windows' | 'unix'
400
401/**
402 * Decide the platform from `uname -s`. On Windows `uname` is usually missing (null); when
403 * Git's tools provide it, it answers MINGW/MSYS/CYGWIN, which is still Windows.
404 */
405export function platformFromUname(output: string | null): Platform {
406 return output && !/MINGW|MSYS|CYGWIN|Windows/i.test(output) ? 'unix' : 'windows'
407}
408
409/** The command that prints every running process's full command line. */
410export function listProcessesArgv(platform: Platform): string[] {
411 return platform === 'windows'
412 ? ['powershell', '-NoProfile', '-Command', 'Get-CimInstance Win32_Process | ForEach-Object CommandLine']
413 : ['ps', '-axo', 'command']
414}
415
416/** Characters that mean something in a regular expression, escaped so pkill matches literally. */
417function escapeForPattern(text: string): string {
418 return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
419}
420
421/**
422 * The command that ends every process whose command line contains `marker`: with its
423 * child processes on Windows (taskkill /T); on macOS/Linux, pkill on the matching ones.
424 */
425export function killMatchingArgv(platform: Platform, marker: string): string[] {
426 if (platform === 'unix') {
427 return ['pkill', '-f', escapeForPattern(marker)]
428 }
429 // PowerShell single-quoted strings escape a quote by doubling it.
430 const literal = marker.replace(/'/g, "''")
431
432 return [
433 'powershell',
434 '-NoProfile',
435 '-Command',
436 `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine.Contains('${literal}') -and $_.CommandLine -notmatch 'Get-CimInstance' } | ForEach-Object { taskkill /PID $_.ProcessId /T /F | Out-Null }`,
437 ]
438}
439
440// ---------------------------------------------------------------------------
441// The lane map from delegate-setup, for the system prompt.
442// ---------------------------------------------------------------------------
443
444/** One lane as delegate-setup writes it in ~/.config/delegate-skills/config.json. */
445export type Lane = { implementer?: string; model?: string; effort?: string; variant?: string; readOnly?: boolean }
446
447/**
448 * The lane map as a few lines for the system prompt, grouped by implementer:
449 * - codex (codex-delegate): implement -> gpt-6-luna; deep-review -> gpt-6.1-sol, read-only
450 * Empty when no lanes are configured; /delegate-setup creates them.
451 */
452export function laneMapText(config: unknown): string {
453 const lanes = (config as { lanes?: Record<string, Lane> } | null)?.lanes
454 if (!lanes || Object.keys(lanes).length === 0) {
455 return ''
456 }
457 const byImplementer = new Map<string, string[]>()
458 for (const [name, lane] of Object.entries(lanes)) {
459 if (!lane.implementer) {
460 continue
461 }
462 const dials = [lane.model, lane.effort && `effort ${lane.effort}`, lane.variant && `variant ${lane.variant}`, lane.readOnly && 'read-only']
463 .filter(Boolean)
464 .join(', ')
465 const entries = byImplementer.get(lane.implementer) ?? []
466 entries.push(dials ? `${name} -> ${dials}` : name)
467 byImplementer.set(lane.implementer, entries)
468 }
469 const lines = [...byImplementer].map(
470 ([implementer, entries]) => `- ${implementer} (${implementer}-delegate): ${entries.join('; ')}`,
471 )
472
473 return [
474 '# Delegation lanes (from delegate-setup)',
475 'Pass `--lane <name>` to the matching relay; an explicit `--model` overrides the lane.',
476 ...lines,
477 ].join('\n')
478}
479
480// ---------------------------------------------------------------------------
481// The bundled worker commands: /codex-* (the official Codex plugin's, vendored under
482// codex/) and /agy-* (the same commands for agy, built on the agy relay).
483// ---------------------------------------------------------------------------
484
485export type Worker = 'codex' | 'agy'
486
487/** The seven commands each worker gets, in the order the typeahead lists them. */
488export const WORKER_ACTIONS = ['rescue', 'review', 'adversarial-review', 'status', 'result', 'cancel', 'setup'] as const
489export type WorkerAction = (typeof WORKER_ACTIONS)[number]
490
491/** One line each for the typeahead. */
492export const ACTION_DESCRIPTIONS: Record<WorkerAction, string> = {
493 rescue: 'Hand a task to {worker}',
494 review: 'Read-only {worker} review of your local changes',
495 'adversarial-review': '{worker} review that challenges the design and its assumptions',
496 status: 'Show {worker} jobs, running and recent',
497 result: "Show a finished {worker} job's report",
498 cancel: 'Cancel a running {worker} job',
499 setup: 'Check that {worker} is installed and signed in',
500}
501
502/**
503 * The lane each model-running command reads its model from, and the model used when that
504 * lane is not configured. An explicit --model on the command beats both.
505 */
506export const COMMAND_MODELS: Record<string, { lane: string; model: string }> = {
507 'codex-rescue': { lane: 'implement', model: 'gpt-6-luna' },
508 'codex-review': { lane: 'review', model: 'gpt-6-luna' },
509 'codex-adversarial-review': { lane: 'deep-review', model: 'gpt-6.1-sol' },
510 'agy-rescue': { lane: 'agy-implement', model: 'gemini-3.8-flash-high' },
511 'agy-review': { lane: 'agy-review', model: 'gemini-3.8-flash-high' },
512 'agy-adversarial-review': { lane: 'third-opinion', model: 'gemini-3.1-pro-high' },
513}
514
515/** The model a command runs: --model if given, else its lane's model, else the default. */
516export function modelFor(command: string, requested: string | null, lanes: Record<string, Lane>): string {
517 const defaults = COMMAND_MODELS[command]
518
519 return requested ?? lanes[defaults.lane]?.model ?? defaults.model
520}
521
522/** A command's arguments with the run flags taken out; `text` is what is left. */
523export type CommandArgs = { background: boolean; model: string | null; base: string | null; text: string }
524
525export function splitArgs(args: string): CommandArgs {
526 let text = args
527 const take = (name: string): string | null => {
528 const value = flag(text, name)
529 text = text.replace(new RegExp(`--${name}\\s+(?:"[^"]+"|'[^']+'|\\S+)`), '')
530
531 return value
532 }
533 const model = take('model')
534 const base = take('base')
535 const background = /--background\b/.test(text)
536 text = text.replace(/--(background|wait)\b/g, '')
537
538 return { background, model, base, text: text.replace(/\s+/g, ' ').trim() }
539}
540
541/** Codex commands pass the arguments on as typed; this adds `--model` when it is missing. */
542export function withModel(args: string, model: string): string {
543 return /--model\s/.test(args) ? args : `--model ${model} ${args}`.trim()
544}
545
546/**
547 * A command file turned into a prompt: the front matter dropped, and each `${KEY}` (and the
548 * Claude Code `$ARGUMENTS`) replaced. Unknown placeholders are left as they are.
549 */
550export function fillTemplate(markdown: string, values: Record<string, string>): string {
551 const body = markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim()
552
553 return body
554 .replace(/\$\{([A-Z_]+)\}/g, (whole, key: string) => values[key] ?? whole)
555 .replace(/\$ARGUMENTS/g, values.ARGUMENTS ?? '')
556}
557
558/** A new agy job id from the clock, short enough to type: agy-<base 36 time>. */
559export function newAgyJobId(now: number): string {
560 return `agy-${now.toString(36)}`
561}
562
563/** What agy-status shows for a job, from its result.json and whether its relay still runs. */
564// "no result": the relay is not running and wrote no result: not started yet, or killed.
565export type AgyJobState = 'running' | 'done' | 'failed' | 'no result'
566
567export function agyJobState(result: { status?: string } | null, alive: boolean): AgyJobState {
568 // A live relay wins: a rerun in the same folder leaves the last run's result.json behind.
569 if (alive) {
570 return 'running'
571 }
572 if (result) {
573 return result.status === 'completed' ? 'done' : 'failed'
574 }
575
576 return 'no result'
577}
578
579/** One agy job as its job.json records it. */
580export type AgyJob = { id: string; kind: string; title: string; model: string; startedAt: number }
581
582/** The agy jobs as a Markdown table, newest first. */
583export function agyStatusTable(rows: Array<AgyJob & { state: AgyJobState }>, now: number): string {
584 if (rows.length === 0) {
585 return 'No agy jobs yet. Start one with /agy-rescue or /agy-review.'
586 }
587 const lines = rows.map(
588 row =>
589 `| ${row.id} | ${row.kind} | ${row.state} | ${row.model} | ${formatElapsed(now - row.startedAt)} ago | ${row.title} |`,
590 )
591
592 return ['| Job | Kind | State | Model | Started | Task |', '|---|---|---|---|---|---|', ...lines].join('\n')
593}
594types/index.d.ts 60 lines1// The values this mod keeps in the session's state, and their shapes.
2
3/** One piece of delegated work: a Claude subagent, or a relay call to Codex, agy, etc. */
4export type Job = {
5 /** The id of the tool call that started it. */
6 id: string
7 /** Who does the work: "Sonnet", "Haiku", "Opus", "Codex", "agy", "Claude CLI", ... */
8 worker: string
9 /** The model, when known ("gpt-6-luna", "claude-sonnet-5-5"), or the lane ("lane implement"). */
10 model: string | null
11 /** A short name for the task, from the delegation's description or brief. */
12 title: string
13 startedAt: number
14 finishedAt: number | null
15 status: 'running' | 'done' | 'failed' | 'stopped'
16 /** Started in the background, so finishing is noticed later rather than right away. */
17 background: boolean
18 /** The brief names one or two files at most: the kind of task solo did better on. */
19 small: boolean
20 /**
21 * Text that appears in the command line of the job's process while it runs: the brief
22 * path for relay.mjs jobs, the job id for background Codex plugin jobs. Used to tell
23 * whether the job is still alive and to stop it. Null when the mod cannot stop it.
24 */
25 aliveMarker: string | null
26 /** For Claude subagents: the subagent's id, which its requests and completion carry. */
27 agentId: string | null
28 /** Opus requests made, and their cost units, while this job was open. */
29 opusRequests: number
30 opusUnits: number
31 /** The worker's own cost units (Claude subagents only; Codex reports no tokens here). */
32 workerUnits: number
33 /** Files the worker changed, when the relay reported it. */
34 filesChanged: number | null
35 /** Still counting Opus requests: true until the turn in which the job finished ends. */
36 isTracking: boolean
37}
38
39/** Delegation totals for one calendar day, across all sessions. */
40export type DayTotals = {
41 jobs: number
42 opusRequests: number
43 opusUnits: number
44 workerUnits: number
45 relayCalls: number
46}
47
48declare module 'claude-code' {
49 interface PluginState {
50 delegation: {
51 /** Every delegation in this session, oldest first. */
52 jobs: Job[]
53 /** Today's totals, shared by all sessions through the store. */
54 today: DayTotals
55 /** The current time, refreshed on a timer so elapsed times redraw. */
56 now: number
57 }
58 }
59}
60