SLOPSHOPPER

delegate-launcher

Buttons above the prompt that start a task for your *-delegate skills, /delegate, a live Delegates pane per relay run with Continue and resume, and a toast…

newpanebandguardcommandtoast
v0.3.0MITupdated 2026-10-12Nasrallah-Adel/claude-delegate-launcher
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · delegate-launcher
│ ┃ Delegate buttons ✕ › fix the failing auth test and add an audit log call │ ┃ No *-delegate skills in ~/.claude/skills or │ ┃ ~/.agents/skills. ⏺ Read(src/auth.ts) │ ┃ [ All ][ None ][ Done ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /delegate │ ⎿ delegate-launcher: Pick which delegates get a button. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Delegate buttons
No *-delegate skills in ~/.claude/skills or ~/.agents/skills. [ All ][ None ][ Done ]
Pane · delegate-runs
new No delegate runs yet. Press a delegate to write a task for it.
README

delegate-launcher

A Claude Code mod for the delegate-skills package by @amElnagdy: one button per installed *-delegate skill above the prompt, so handing a task to Codex, Antigravity, Copilot, OpenCode or another Claude Code session is one click away.

delegate [ agy ] [ codex ] [ copilot ] [ ⚙ ] [ runs 1… ]
┌ Delegates ─────────────────────────────────────────────┐
│ ▸ … codex running · 3m                                 │
│   ✓ agy completed · 12m                                │
│ ── codex · /var/folders/…/delegate-launcher/codex-17…  │
│ item.started: npm test                                 │
│ item.completed: Edited src/billing/invoice.ts          │
│ [ Continue ] [ Copy resume ] [ Clear finished ]        │
└────────────────────────────────────────────────────────┘

The skills do the real work: write the brief, dispatch it through relay.mjs, review the diff and land it. This mod is only the launcher and the "it finished" signal. It never runs a relay itself and never commits.

What it does

PieceBehaviour
SetupFinds the *-delegate skills in ~/.claude/skills and ~/.agents/skills. Until you pick, the band shows one Set up button; it, ⚙, or /delegate config opens a pane where you tick which delegates get a button (All / None / Done). The pick is kept across sessions.
ButtonsA press puts /codex-delegate in the prompt box. You write the task and press Enter. A draft you already typed becomes the task; pressing another delegate swaps the prefix.
/delegate/delegate codex <task> sends the task at once. /delegate agy (no task) fills the box. /delegate list names what is installed.
Done toastWhen the model runs a delegate's relay.mjs (node …/relay.mjs), the mod gives the run an --out-dir (unless it has one), shows ⇢ codex running in the status line, and toasts when result.json lands: codex finished: completed, 3 files touched — review the diff. A run that ends without a result.json says so.
On start and /clearEach new session, and each /clear, re-scans your delegates, restores the recent runs and opens the Delegates pane (turn off with openPaneOnStart in /config). The pane has a new [codex] [agy] … row, so it works as a launcher before any run exists.
Delegates paneAlso opens when a run starts (turn off with autoOpenPane), or from the band's runs button or /delegate watch. Lists the last 10 runs; the selected one shows its live log (events.jsonl, or agy.log for agy), one line per event, refreshed every 2 s.
ContinueOn a finished run with a session id: puts /codex-delegate Continue the earlier codex run with relay --session <id> --cd <repo> and send only this follow-up: in the prompt box. Type the follow-up, press Enter; the skill resumes the same CLI session.
Copy resumeCopies the CLI's own interactive resume command (codex resume <id>, claude --resume <id>, copilot --resume=<id>, opencode --session <id>, agy --conversation <id>) to open that session yourself in another terminal.

Install

First install the delegate skills you want (see the delegate-skills README):

npx skills add amElnagdy/delegate-skills

Then the mod:

/plugin install delegate-launcher --marketplace Nasrallah-Adel/claude-delegate-launcher

Or clone it into ~/.claude/skills/delegate-launcher and run /reload-plugins.

Limits

  • An --out-dir written as $VAR, ~/… or a relative path is not tracked (the mod cannot resolve it).
  • A background run that never writes result.json drops off the status line after 3 hours.
  • With two sessions open, both may toast for the same run.
  • The pane shows a one-line summary per event, not the other CLI's own screen; agy's plain-text log reads rougher.
  • Fleet lanes from delegate-setup (--lane feature) are not shown as buttons yet.

Develop

claude plugin validate .
claude plugin test .

Credits

delegate-skills (MIT) by @amElnagdy provides the *-delegate skills, their relays and the delegate-relay.result.v1 contract this mod reads.

License

MIT

Source 4 files
hooks/register.tsx 403 lines
1/**
2 * delegate-launcher: one button per *-delegate skill above the prompt.
3 *
4 *   delegate [ codex ] [ agy ] [ copilot ] [ ⚙ ] [ runs 1 ]
5 *
6 * A press puts `/codex-delegate ` in the prompt box (keeping a draft already
7 * typed as the task); you write the task and press Enter. `⚙` or
8 * `/delegate config` picks which delegates get a button. `/delegate codex
9 * <task>` sends a task straight away.
10 *
11 * When the model runs a delegate's relay.mjs, the mod gives it an --out-dir,
12 * follows its live log in the Delegates pane (`runs`, `/delegate watch`), and
13 * toasts when result.json lands. A finished run offers Continue (the same CLI
14 * session, through the skill) and Copy resume (the CLI's own resume command).
15 */
16import { atom, read, update } from 'claude-code'
17import type { EngineInterface, Register } from 'claude-code'
18import type { Run } from '../types'
19import {
20  OUT_ROOT,
21  SKILL_DIRS,
22  collectDelegates,
23  delegatePrompt,
24  findRelayRun,
25  isWatchableDir,
26  parseDelegateArgs,
27  parseResult,
28  prefillText,
29  resolveSkill,
30  resultLine,
31  shortName,
32  withOutDir,
33} from './delegates'
34import {
35  TAIL_LINES,
36  addRun,
37  age,
38  continueText,
39  isRunning,
40  liveLogName,
41  newRun,
42  patchRun,
43  resultFields,
44  resumeCommand,
45  statusMark,
46  summarizeEvent,
47} from './runs'
48
49const CONFIG_PANE = 'delegate-config'
50const RUNS_PANE = 'delegate-runs'
51const POLL_MS = 2_000
52const RUN_TTL_MS = 3 * 60 * 60 * 1000
53const CLEAR_DELAY_MS = 300
54const STORE_ENABLED = 'enabled'
55const STORE_RUNS = 'runs'
56
57const found = atom({ plugin: 'delegate-launcher', key: 'found' } as const, [])
58const enabled = atom({ plugin: 'delegate-launcher', key: 'enabled' } as const, null)
59const runs = atom({ plugin: 'delegate-launcher', key: 'runs' } as const, [])
60const selected = atom({ plugin: 'delegate-launcher', key: 'selected' } as const, null)
61const tail = atom({ plugin: 'delegate-launcher', key: 'tail' } as const, [])
62
63/** The poll timer; a hot reload starts it over and session.start resumes it. */
64let timer: { cancel: () => void } | undefined
65
66const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err))
67
68async function discover($: EngineInterface): Promise<string[]> {
69  const home = await $.env.get('HOME')
70  if (!home) return []
71  const lists = await Promise.all(SKILL_DIRS.map((dir) => $.fs.list(`${home}/${dir}`).catch(() => [])))
72  const names = lists.flat().filter((entry) => entry.kind !== 'file').map((entry) => entry.name)
73  return collectDelegates(names)
74}
75
76async function setEnabled($: EngineInterface, next: string[]): Promise<void> {
77  await update($, enabled, () => next)
78  await $.store.set(STORE_ENABLED, next)
79}
80
81async function setRuns($: EngineInterface, fn: (list: Run[]) => Run[]): Promise<Run[]> {
82  await update($, runs, fn)
83  const list = await read($, runs)
84  await $.store.set(STORE_RUNS, list)
85  const running = list.filter(isRunning)
86  $.ui.status(running.length === 0 ? undefined : `⇢ ${running.map((r) => shortName(r.skill)).join(', ')} running`)
87  return list
88}
89
90/** Prefills the prompt box, telling the person when it could not. */
91async function fill($: EngineInterface, text: string): Promise<void> {
92  const filled = await $.prompt.fill({ text }).catch(() => ({ isFilled: false }))
93  if (!filled.isFilled) $.ui.toast(`delegate-launcher: could not fill the prompt; type: ${text}`)
94}
95
96/** Prefills the prompt box with the skill's command, keeping any draft. */
97async function prefill($: EngineInterface, skill: string): Promise<void> {
98  const draft = await $.prompt.read()
99  await fill($, prefillText(skill, draft.text))
100}
101
102/** A command.run hook may not submit (it would wait on itself), so submit once it returns. */
103function submitLater($: EngineInterface, text: string): void {
104  $.clock.after(0, () => {
105    $.prompt.submit({ text, asUser: true }).catch((err) => $.ui.toast(`delegate-launcher: could not send: ${errText(err)}`))
106  })
107}
108
109function openRuns($: EngineInterface, focus: boolean): void {
110  void $.ui.open(focus ? { id: RUNS_PANE, title: 'Delegates', focus: true } : { id: RUNS_PANE, title: 'Delegates' })
111}
112
113/** Reads the selected run's last log lines into `tail`. */
114async function refreshTail($: EngineInterface): Promise<void> {
115  const outDir = await read($, selected)
116  const run = (await read($, runs)).find((r) => r.outDir === outDir)
117  if (!run) {
118    await update($, tail, () => [])
119    return
120  }
121  const path = `${run.outDir}/${liveLogName(run.skill)}`
122  if (!(await $.fs.exists(path))) {
123    await update($, tail, () => ['(no log yet)'])
124    return
125  }
126  const { stdout } = await $.process.run(['tail', '-n', String(TAIL_LINES * 3), path], { timeoutMs: 5_000 })
127  const lines = stdout.split('\n').map(summarizeEvent).filter((l): l is string => l !== undefined)
128  await update($, tail, () => lines.slice(-TAIL_LINES))
129}
130
131async function select($: EngineInterface, outDir: string): Promise<void> {
132  await update($, selected, () => outDir)
133  await refreshTail($)
134}
135
136/** Closes a run: toast its result (or that it left none) and keep its session id. */
137async function settle($: EngineInterface, run: Run, now: number): Promise<void> {
138  const path = `${run.outDir}/result.json`
139  if (!(await $.fs.exists(path))) {
140    if (now - run.startedAt <= RUN_TTL_MS) return
141    await setRuns($, (list) => patchRun(list, run.outDir, { status: 'no-result', finishedAt: now }))
142    return
143  }
144  try {
145    const result = parseResult(await $.fs.read(path))
146    const line = resultLine(run.skill, result)
147    await setRuns($, (list) =>
148      patchRun(list, run.outDir, { status: result.status, touched: result.touched, finishedAt: now, ...resultFields(result.data) }),
149    )
150    $.ui.toast(line, { timeoutMs: 10_000 })
151    $.ui.log(`delegate-launcher: ${line} (${path})`)
152  } catch (err) {
153    $.ui.log(`delegate-launcher: could not read ${path}: ${errText(err)}`, { to: 'debug' })
154    await setRuns($, (list) => patchRun(list, run.outDir, { status: 'unreadable', finishedAt: now }))
155  }
156}
157
158async function poll($: EngineInterface): Promise<void> {
159  const now = await $.clock.now()
160  for (const run of (await read($, runs)).filter(isRunning)) await settle($, run, now)
161  await refreshTail($)
162  if ((await read($, runs)).every((r) => !isRunning(r))) stopPolling()
163}
164
165/** A foreground run is over when its call returns: settle it, or say it left nothing. */
166async function finishForeground($: EngineInterface, outDir: string): Promise<void> {
167  await poll($)
168  const run = (await read($, runs)).find((r) => r.outDir === outDir)
169  if (!run || !isRunning(run)) return
170  const now = await $.clock.now()
171  await setRuns($, (list) => patchRun(list, outDir, { status: 'no-result', finishedAt: now }))
172  $.ui.toast(`${shortName(run.skill)} relay ended without a result.json — check its output`)
173}
174
175function startPolling($: EngineInterface): void {
176  timer ??= $.clock.every(POLL_MS, () => {
177    poll($).catch((err) => $.ui.log(`delegate-launcher: poll failed: ${errText(err)}`, { to: 'debug' }))
178  })
179}
180function stopPolling(): void {
181  timer?.cancel()
182  timer = undefined
183}
184
185async function copyResume($: EngineInterface, run: Run, surface: string | undefined): Promise<void> {
186  const command = resumeCommand(run)
187  if (!command) return
188  const copied = await $.ui.copy(surface ? { text: command, surface: surface as never } : { text: command })
189  $.ui.toast(copied.isCopied ? `Copied: ${command}` : `Copy failed; run: ${command}`)
190}
191
192/** Discovers delegates, restores the pick and the runs, resumes polling, opens the pane. */
193async function boot($: EngineInterface, openPane: boolean): Promise<void> {
194  await $.command.register({
195    name: 'delegate',
196    description: 'Delegate a task: /delegate <codex|agy|…> <task>, /delegate watch, /delegate config, /delegate list',
197    argumentHint: '<name> <task> | watch | config | list',
198  })
199  const skills = await discover($)
200  await update($, found, () => skills)
201  const stored = await $.store.get(STORE_ENABLED)
202  const picked = Array.isArray(stored) ? stored.filter((s): s is string => skills.includes(String(s))) : null
203  await update($, enabled, () => picked)
204  const saved = await $.store.get(STORE_RUNS)
205  if (Array.isArray(saved) && saved.length > 0) {
206    const list = await setRuns($, () => saved as Run[])
207    await update($, selected, () => list[0]?.outDir ?? null)
208    await refreshTail($).catch(() => undefined)
209    if (list.some(isRunning)) startPolling($)
210  }
211  if (picked === null && skills.length > 0) {
212    $.ui.toast(`delegate-launcher: found ${skills.length} delegates; press Set up above the prompt or run /delegate config`)
213  }
214  if (openPane && skills.length > 0) openRuns($, false)
215}
216
217export const register: Register = (on, options) => {
218  const autoOpen = options.autoOpenPane !== false
219  const openOnStart = options.openPaneOnStart !== false
220
221  on('session.start', async ($, e, next) => {
222    await boot($, openOnStart)
223    return next(e)
224  })
225
226  // /clear raises no session.start: start over once the old conversation has ended.
227  on('session.end', async ($, e, next) => {
228    const ended = await next(e)
229    if (e.reason === 'clear') {
230      $.clock.after(CLEAR_DELAY_MS, () => {
231        boot($, openOnStart).catch((err) => $.ui.log(`delegate-launcher: start after /clear failed: ${errText(err)}`, { to: 'debug' }))
232      })
233    }
234    return ended
235  })
236
237  on('command.run', { command: 'delegate' }, async ($, e) => {
238    const args = parseDelegateArgs(e.args)
239    const skills = await read($, found)
240    if (args.kind === 'config') {
241      await $.ui.open({ id: CONFIG_PANE, title: 'Delegate buttons', focus: true })
242      return { text: 'Pick which delegates get a button.' }
243    }
244    if (args.kind === 'list') {
245      return { text: skills.length === 0 ? 'No *-delegate skills found.' : `Delegates: ${skills.map(shortName).join(', ')}` }
246    }
247    if (args.kind === 'watch') {
248      openRuns($, true)
249      return { text: 'Delegates pane opened.' }
250    }
251    const skill = resolveSkill(skills, args.name)
252    if (!skill) {
253      return { text: `Unknown delegate "${args.name}". Installed: ${skills.map(shortName).join(', ') || 'none'}` }
254    }
255    if (args.task === '') {
256      await prefill($, skill)
257      return { text: `Write the task for ${shortName(skill)} and press Enter.` }
258    }
259    submitLater($, delegatePrompt(skill, args.task))
260    return { text: `Sent to ${shortName(skill)}.` }
261  })
262
263  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
264    const relay = findRelayRun(e.command)
265    if (!relay || (relay.outDir !== undefined && !isWatchableDir(relay.outDir))) return next(e)
266    let outDir: string
267    let command: string
268    try {
269      const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')
270      const startedAt = await $.clock.now()
271      outDir = relay.outDir ?? `${tmp}/${OUT_ROOT}/${shortName(relay.skill)}-${startedAt}`
272      command = relay.outDir ? e.command : withOutDir(e.command, outDir)
273      await setRuns($, (list) => addRun(list.filter((r) => r.outDir !== outDir), newRun(relay.skill, outDir, startedAt)))
274      await update($, selected, () => outDir)
275      await update($, tail, () => ['(starting)'])
276    } catch (err) {
277      $.ui.log(`delegate-launcher: not watching this relay run: ${errText(err)}`, { to: 'debug' })
278      return next(e)
279    }
280    if (autoOpen) openRuns($, false)
281    startPolling($)
282    if (e.run_in_background === true) return next({ ...e, command })
283    const ran = await next({ ...e, command })
284    await finishForeground($, outDir)
285    return ran
286  })
287
288  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
289    if (e.props.hasSurvey) return next(e)
290    const skills = await read($, found)
291    const list = await read($, runs)
292    if (skills.length === 0 && list.length === 0) return next(e)
293    const picked = await read($, enabled)
294    const { Box, Button, Text } = $.ui.resolve(e)
295    const below = await next(e)
296    const openConfig = () => void $.ui.open({ id: CONFIG_PANE, title: 'Delegate buttons', focus: true })
297    const running = list.filter(isRunning).length
298    return (
299      <Box flexDirection="column">
300        <Box>
301          <Text dimColor>delegate </Text>
302          {picked === null ? (
303            <Button key="setup" label="Set up" onPress={openConfig} />
304          ) : (
305            picked.map((skill) => (
306              <Button key={`d-${skill}`} label={shortName(skill)} onPress={() => void prefill($, skill)} />
307            ))
308          )}
309          {picked !== null && <Button key="config" label="⚙" onPress={openConfig} />}
310          {list.length > 0 && (
311            <Button key="runs" label={running > 0 ? `runs ${running}…` : 'runs'} onPress={() => openRuns($, true)} />
312          )}
313        </Box>
314        {below}
315      </Box>
316    )
317  })
318
319  on('ui.render', { component: 'Pane', requestId: CONFIG_PANE }, async ($, e) => {
320    const { Box, Button, Text } = $.ui.resolve(e)
321    const skills = await read($, found)
322    const picked = (await read($, enabled)) ?? []
323    const toggle = (skill: string) =>
324      void setEnabled($, picked.includes(skill) ? picked.filter((s) => s !== skill) : [...picked, skill].sort())
325    return (
326      <Box flexDirection="column">
327        {skills.length === 0 && <Text dimColor>No *-delegate skills in ~/.claude/skills or ~/.agents/skills.</Text>}
328        {skills.length > 0 && <Text dimColor>Press a delegate to show or hide its button above the prompt.</Text>}
329        {skills.map((skill) => (
330          <Button
331            key={`t-${skill}`}
332            label={`${picked.includes(skill) ? '[x]' : '[ ]'} ${shortName(skill)}`}
333            onPress={() => toggle(skill)}
334          />
335        ))}
336        <Box>
337          <Button key="all" label="All" onPress={() => void setEnabled($, [...skills])} />
338          <Button key="none" label="None" onPress={() => void setEnabled($, [])} />
339          <Button key="done" label="Done" onPress={() => void $.ui.close({ id: CONFIG_PANE })} />
340        </Box>
341      </Box>
342    )
343  })
344
345  on('ui.render', { component: 'Pane', requestId: RUNS_PANE }, async ($, e) => {
346    const { Box, Button, Text } = $.ui.resolve(e)
347    const list = await read($, runs)
348    const now = await $.clock.now()
349    const chosen = await read($, selected)
350    const current = list.find((r) => r.outDir === chosen) ?? list[0]
351    const lines = await read($, tail)
352    const picked = (await read($, enabled)) ?? (await read($, found))
353    const launch = (
354      <Box>
355        <Text dimColor>new </Text>
356        {picked.map((skill) => (
357          <Button key={`p-${skill}`} label={shortName(skill)} onPress={() => void prefill($, skill)} />
358        ))}
359      </Box>
360    )
361    if (!current) {
362      return (
363        <Box flexDirection="column">
364          {launch}
365          <Text dimColor>No delegate runs yet. Press a delegate to write a task for it.</Text>
366        </Box>
367      )
368    }
369    const resume = resumeCommand(current)
370    return (
371      <Box flexDirection="column">
372        {launch}
373        {list.map((run) => (
374          <Button
375            key={`r-${run.outDir}`}
376            label={`${run === current ? '▸' : ' '} ${statusMark(run)} ${shortName(run.skill)} ${run.status} · ${age((run.finishedAt ?? now) - run.startedAt)}`}
377            onPress={() => void select($, run.outDir)}
378          />
379        ))}
380        <Text dimColor>{`── ${shortName(current.skill)} · ${current.outDir}`}</Text>
381        {lines.map((line) => (
382          <Text>{line}</Text>
383        ))}
384        {!isRunning(current) && (
385          <Box>
386            {current.sessionId !== null && (
387              <Button key="continue" label="Continue" onPress={() => void fill($, continueText(current))} />
388            )}
389            {resume !== undefined && (
390              <Button key="resume" label="Copy resume" onPress={(press) => void copyResume($, current, press?.surface)} />
391            )}
392            <Button
393              key="clear"
394              label="Clear finished"
395              onPress={() => void setRuns($, (all) => all.filter(isRunning))}
396            />
397          </Box>
398        )}
399      </Box>
400    )
401  })
402}
403
hooks/delegates.ts 106 lines
1/**
2 * Pure helpers for delegate-launcher: naming, the text a button or /delegate
3 * puts in front of the model, spotting a relay.mjs run in a Bash command, and
4 * reading its result.json. Nothing here touches `$`.
5 */
6
7/** Where skills live, relative to $HOME. */
8export const SKILL_DIRS = ['.claude/skills', '.agents/skills'] as const
9
10/** Out dirs for runs the mod watches, under $TMPDIR. */
11export const OUT_ROOT = 'delegate-launcher'
12
13const DELEGATE_NAME = /^[a-z0-9][a-z0-9-]*-delegate$/
14// Only `node … relay.mjs` runs it; a grep, cat or sed that names the path does not.
15const RELAY = /(?:^|[\s;&|(])node\s+(?:-\S+\s+)*["']?[^"'\s]*?([A-Za-z0-9_.-]+-delegate)\/scripts\/relay\.mjs["']?/
16const OUT_DIR = /--out-dir(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/
17const HELP = /(^|\s)(--help|-h)(\s|$)/
18const SAFE_PATH = /^[A-Za-z0-9_./-]+$/
19const SLASH_PREFIX = /^\/[a-z0-9-]+-delegate\s*/
20
21export const isDelegateName = (name: string): boolean => DELEGATE_NAME.test(name)
22
23/** `codex-delegate` → `codex`. */
24export const shortName = (skill: string): string => skill.replace(/-delegate$/, '')
25
26/** Unique, sorted delegate skill names from several directory listings. */
27export function collectDelegates(names: readonly string[]): string[] {
28  return [...new Set(names.filter(isDelegateName))].sort()
29}
30
31/** `codex` or `codex-delegate` → the found skill, if any. */
32export function resolveSkill(found: readonly string[], word: string): string | undefined {
33  const w = word.trim().toLowerCase()
34  if (w === '') return undefined
35  return found.find((skill) => skill === w || shortName(skill) === w)
36}
37
38/**
39 * What a button puts in the prompt box: the skill's slash command, keeping
40 * any draft already typed as the task (and swapping a previous delegate's
41 * prefix rather than stacking two).
42 */
43export function prefillText(skill: string, draft: string): string {
44  const task = draft.replace(SLASH_PREFIX, '').trim()
45  return task === '' ? `/${skill} ` : `/${skill} ${task}`
46}
47
48/** What /delegate submits for the model. */
49export const delegatePrompt = (skill: string, task: string): string =>
50  `Use the ${skill} skill to delegate this task, then review and land the result: ${task.trim()}`
51
52export type DelegateArgs =
53  | { kind: 'config' }
54  | { kind: 'list' }
55  | { kind: 'watch' }
56  | { kind: 'pick'; name: string; task: string }
57
58/** `/delegate`, `/delegate config`, `/delegate list`, `/delegate watch`, `/delegate codex fix x`. */
59export function parseDelegateArgs(args: string): DelegateArgs {
60  const text = args.trim()
61  if (text === '' || text === 'config') return { kind: 'config' }
62  if (text === 'list') return { kind: 'list' }
63  if (text === 'watch' || text === 'runs') return { kind: 'watch' }
64  const [name, ...rest] = text.split(/\s+/)
65  return { kind: 'pick', name: name ?? '', task: rest.join(' ') }
66}
67
68export type RelayRun = { skill: string; outDir?: string }
69
70/** The delegate a Bash command runs relay.mjs for, and its --out-dir if given. */
71export function findRelayRun(command: string): RelayRun | undefined {
72  const match = RELAY.exec(command)
73  if (!match || HELP.test(command)) return undefined
74  const out = OUT_DIR.exec(command)
75  const outDir = out ? out[1] ?? out[2] ?? out[3] : undefined
76  return match[1] ? { skill: match[1], outDir } : undefined
77}
78
79/** Whether the mod can find result.json under `dir` as typed (no `$VAR`, `~`, or relative path). */
80export const isWatchableDir = (dir: string): boolean => dir.startsWith('/') && SAFE_PATH.test(dir)
81
82/** The command with `--out-dir '<dir>'` right after the first relay.mjs. */
83export function withOutDir(command: string, dir: string): string {
84  if (!SAFE_PATH.test(dir)) throw new Error(`unsafe out dir: ${dir}`)
85  return command.replace(RELAY, (whole) => `${whole} --out-dir '${dir}'`)
86}
87
88export type RelayResult = { status: string; touched: number | null; data: Record<string, unknown> }
89
90/** Reads the fields the toast needs; rejects anything not a relay result. */
91export function parseResult(text: string): RelayResult {
92  const data: unknown = JSON.parse(text)
93  if (typeof data !== 'object' || data === null) throw new Error('result.json is not an object')
94  const { status, touchedFiles } = data as { status?: unknown; touchedFiles?: unknown }
95  if (typeof status !== 'string') throw new Error('result.json has no status')
96  return { status, touched: Array.isArray(touchedFiles) ? touchedFiles.length : null, data: data as Record<string, unknown> }
97}
98
99/** `codex finished: completed, 3 files touched — review the diff`. */
100export function resultLine(skill: string, result: RelayResult): string {
101  const files =
102    result.touched === null ? '' : `, ${result.touched} file${result.touched === 1 ? '' : 's'} touched`
103  const tail = result.status === 'completed' ? ' — review the diff' : ''
104  return `${shortName(skill)} finished: ${result.status}${files}${tail}`
105}
106
hooks/runs.ts 116 lines
1/**
2 * Pure helpers for the Delegates pane: the run record, a one-line view of a
3 * relay event, the follow-up text for Continue, and each CLI's own resume
4 * command. Nothing here touches `$`.
5 */
6import type { Run } from '../types'
7import { shortName } from './delegates'
8
9/** Runs kept in the pane, newest first. */
10export const MAX_RUNS = 10
11
12/** Event lines shown for the selected run. */
13export const TAIL_LINES = 14
14
15const LINE_WIDTH = 110
16
17/** The live log each relay appends to while it runs; agy writes plain text. */
18export const liveLogName = (skill: string): string => (shortName(skill) === 'agy' ? 'agy.log' : 'events.jsonl')
19
20export const newRun = (skill: string, outDir: string, startedAt: number): Run => ({
21  skill, outDir, startedAt, status: 'running', sessionId: null, touched: null, workdir: null, finishedAt: null,
22})
23
24/** Adds a run at the top, keeping the newest MAX_RUNS. */
25export const addRun = (runs: readonly Run[], run: Run): Run[] => [run, ...runs].slice(0, MAX_RUNS)
26
27/** Replaces the run with this outDir by `patch`. */
28export const patchRun = (runs: readonly Run[], outDir: string, patch: Partial<Run>): Run[] =>
29  runs.map((run) => (run.outDir === outDir ? { ...run, ...patch } : run))
30
31export const isRunning = (run: Run): boolean => run.status === 'running'
32
33/** `4m`, `1h 05m`, `12s`. */
34export function age(ms: number): string {
35  const s = Math.max(0, Math.round(ms / 1000))
36  if (s < 60) return `${s}s`
37  const m = Math.floor(s / 60)
38  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
39}
40
41export function statusMark(run: Run): string {
42  if (isRunning(run)) return '…'
43  return run.status === 'completed' ? '✓' : '✗'
44}
45
46/** The fields of result.json the pane keeps. */
47export function resultFields(data: Record<string, unknown>): Pick<Run, 'sessionId' | 'workdir'> {
48  const id = [data.threadId, data.sessionId, data.conversationId, data.session].find(
49    (v): v is string => typeof v === 'string' && v !== '',
50  )
51  return { sessionId: id ?? null, workdir: typeof data.workdir === 'string' ? data.workdir : null }
52}
53
54const oneLine = (text: string): string => {
55  const flat = text.replace(/\s+/g, ' ').trim()
56  return flat.length > LINE_WIDTH ? `${flat.slice(0, LINE_WIDTH - 1)}…` : flat
57}
58
59const firstText = (value: unknown): string | undefined => {
60  if (typeof value === 'string') return value
61  if (Array.isArray(value)) {
62    return value.map(firstText).filter((t): t is string => t !== undefined).join(' ') || undefined
63  }
64  if (typeof value === 'object' && value !== null) {
65    const o = value as Record<string, unknown>
66    for (const key of ['text', 'command', 'content', 'name', 'path', 'message']) {
67      const found = firstText(o[key])
68      if (found) return found
69    }
70  }
71  return undefined
72}
73
74/** One relay event as a short line: `type: text`. A non-JSON line is kept as is. */
75export function summarizeEvent(line: string): string | undefined {
76  const raw = line.trim()
77  if (raw === '') return undefined
78  let data: unknown
79  try {
80    data = JSON.parse(raw)
81  } catch {
82    return oneLine(raw)
83  }
84  if (typeof data !== 'object' || data === null) return oneLine(raw)
85  const o = data as Record<string, unknown>
86  const nested = (k: string) => (typeof o[k] === 'object' && o[k] !== null ? (o[k] as Record<string, unknown>) : {})
87  const type = [o.type, o.event, nested('item').type, nested('msg').type, nested('part').type].find(
88    (v): v is string => typeof v === 'string',
89  )
90  const text = firstText(o.item) ?? firstText(o.part) ?? firstText(o.msg) ?? firstText(o.message) ?? firstText(o.text) ?? firstText(o.data)
91  if (!type && !text) return undefined
92  return oneLine([type, text].filter(Boolean).join(': '))
93}
94
95/** What Continue puts in the prompt box: the skill, the session, room for the follow-up. */
96export function continueText(run: Run): string {
97  const cd = run.workdir ? ` --cd ${run.workdir}` : ''
98  return `/${run.skill} Continue the earlier ${shortName(run.skill)} run with relay --session ${run.sessionId}${cd} and send only this follow-up: `
99}
100
101const RESUME: Record<string, (id: string) => string> = {
102  codex: (id) => `codex resume ${id}`,
103  claude: (id) => `claude --resume ${id}`,
104  copilot: (id) => `copilot --resume=${id}`,
105  opencode: (id) => `opencode --session ${id}`,
106  agy: (id) => `agy --conversation ${id}`,
107}
108
109/** The CLI's own command to open this session interactively, if known. */
110export function resumeCommand(run: Run): string | undefined {
111  const make = RESUME[shortName(run.skill)]
112  if (!make || !run.sessionId) return undefined
113  const cd = run.workdir ? `cd '${run.workdir}' && ` : ''
114  return `${cd}${make(run.sessionId)}`
115}
116
types/index.d.ts 31 lines
1/** One relay run the mod saw start: running, or finished with its result. */
2export type Run = {
3  skill: string
4  outDir: string
5  startedAt: number
6  /** `running`, a result.json status (`completed`, `failed`, …), or `no-result`. */
7  status: string
8  /** The CLI's session id from result.json (threadId / sessionId / conversationId). */
9  sessionId: string | null
10  touched: number | null
11  workdir: string | null
12  finishedAt: number | null
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'delegate-launcher': {
18      /** `*-delegate` skill names found on disk, sorted. */
19      found: string[]
20      /** The skills that get a button; `null` until the person configures it. */
21      enabled: string[] | null
22      /** The last runs, newest first. */
23      runs: Run[]
24      /** The outDir of the run the pane shows. */
25      selected: string | null
26      /** The selected run's last log lines, summarized. */
27      tail: string[]
28    }
29  }
30}
31