SLOPSHOPPER

delegation

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

newpanebandguardcommandtoast
v0.5.0MITupdated 2026-10-07MohamedHamed001/claude-delegation
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · delegation
│ ┃ Delegations ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ╭────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ Running now (0) │ ⎿ Read 6 lines │ ┃ │ │ ⏺ Update(src/auth.ts) │ ┃ │ Nothing delegated right now │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ This session (0) │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ │ ┃ │ No delegations yet in this session │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰────────────────────────────────────────╯ │ ┃ › /delegations │ ┃ ╭────────────────────────────────────────╮ ⎿ delegation: Delegations pane opened. │ ┃ │ Today, all sessions │ │ ┃ │ │ │ ┃ │ Delegations 0 │ │ ┃ │ Opus requests while delegating 0 │ │ ┃ │ Opus units while delegating 0 │ │ ┃ │ Claude worker units 0 │ │ ┃ │ Relay calls (Codex and others) 0 │ │ ┃ ╰────────────────────────────────────────╯ │ ┃ │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ From your cost test (6 Oct) │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Delegations
╭────────────────────────────────────────────────────────╮ │ Running now (0) │ │ │ │ Nothing delegated right now │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ This session (0) │ │ │ │ No delegations yet in this session │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ Today, all sessions │ │ │ │ Delegations 0 │ │ Opus requests while delegating 0 │ │ Opus units while delegating 0 │ │ Claude worker units 0 │ │ Relay calls (Codex and others) 0 │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ From your cost test (6 Oct) │ │ │ │ Opus solo: passed 3 of 3, fastest │ │ Codex: about 10% cheaper on Claude, 2 to 3 times │ │ slower │ │ Sonnet: most expensive every time │ ╰────────────────────────────────────────────────────────╯
README

claude-delegation

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.

Install

/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.

Requirements

  • Claude Code 2.1.28x or newer. The plugins use function hooks (TypeScript modules the app loads), which older versions do not run.
  • Windows, macOS or Linux. Process handling is detected per session: PowerShell on Windows, ps and pkill elsewhere. Developed on Windows; macOS has not been tested yet.
  • For delegation only, the workers you want to use:
  • Node.js 18 or newer (the relay and Codex scripts are Node scripts).
  • Codex: npm install -g @openai/codex, then codex login.
  • agy: the Google Antigravity CLI, signed in by running agy once.
  • Claude subagents need nothing extra.

What it does

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.

Why it exists

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:

SetupPassedClaude-side costTime
Opus alone3 of 3baselinefastest
Opus + Sonnet subagent2 of 3about a third moreslower
Opus + Codex2 of 3about a tenth less2 to 3 times slower

So the plugin's default is: do the work yourself, and delegate only when it pays.

What you get

PartWhat it does
PolicyAdded 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).
EnforcementA 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 wordsSaying "delegate this", "hand this over" or naming a worker (Codex, agy, Sonnet) counts as asking, no slash command needed.
BandOne line above the prompt while a job runs, when it finishes, or when it has run for 15+ minutes, with a Stop button.
PaneDelegations 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.
Skillscodex-delegate, agy-delegate, claude-delegate, delegate-setup, gpt-5-4-prompting, codex-result-handling.

Commands

CommandWhat 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-setupChooses which worker and model handles which kind of work (writes the lane map)
/delegationsOpens 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)
reviewRead-only review of your local changesRead-only review of your local changes
adversarial-review [focus]Review that challenges the design and its assumptionsSame, on agy's stronger model
statusRunning and recent jobsRunning and recent jobs
result [job-id]A finished job's full reportA finished job's report
cancel [job-id]Stops a running jobStops a running job
setupChecks Node, Codex and its sign-inChecks 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.

Which model a command uses

--model on the command wins; otherwise the command's lane in your lane map; otherwise:

CommandLaneDefault model
/codex-rescueimplementgpt-6-luna
/codex-reviewreviewgpt-6-luna
/codex-adversarial-reviewdeep-reviewgpt-6.1-sol
/agy-rescueagy-implementgemini-3.8-flash-high
/agy-reviewagy-reviewgemini-3.8-flash-high
/agy-adversarial-reviewthird-opiniongemini-3.1-pro-high
/exploreresearchgemini-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.

First run

  1. Install the workers you want (see Requirements).
  2. Run /codex-setup and /agy-setup to check them.
  3. Run /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.

Things to know

  • agy and permissions. agy runs headless and cannot ask for permission, so any action that needs approval is refused and the run fails. /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.
  • agy job files. Each agy job keeps its files in ~/.claude/delegation/agy-jobs/<id>/: job.json, brief.md, diff.patch (reviews), result.json and agy's logs.
  • Keep reviews small. A fast model asked to review a large diff can explore the repository for a long time. Review a few files at a time; the relay stops a run at its timeout.
  • The official Codex plugin. This plugin bundles it (see Credits). If 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.
  • Sol asks first. Sol (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.
  • Cost figures. Claude subagents report tokens, so their cost is shown. Codex and agy report nothing to Claude, so they show as calls and time, with the Opus requests made while they ran.

How it knows what is running

SourceWhat it tells the plugin
The Agent tool call and agent.spawnA Claude subagent started, its id and its real model
The subagent's own requests and turn.completeWhat 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 openWhat delegating cost on the Opus side

Developing

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.

Licence

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:

  • The skills codex-delegate, agy-delegate, claude-delegate and delegate-setup from amElnagdy/delegate-skills (MIT).
  • The Codex companion, its command texts and the skills 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.

Source 3 files
hooks/register.tsx 1006 lines
1// 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}
1006
hooks/logic.ts 594 lines
1// 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}
594
types/index.d.ts 60 lines
1// 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