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…

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.
| Piece | Behaviour |
|---|---|
| Setup | Finds 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. |
| Buttons | A 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 toast | When 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 /clear | Each 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 pane | Also 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. |
| Continue | On 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 resume | Copies 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. |
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.
--out-dir written as $VAR, ~/… or a relative path is not tracked (the mod cannot resolve it).result.json drops off the status line after 3 hours.delegate-setup (--lane feature) are not shown as buttons yet.claude plugin validate .
claude plugin test .
delegate-skills (MIT) by @amElnagdy provides the *-delegate skills, their relays and the delegate-relay.result.v1 contract this mod reads.
MIT
hooks/register.tsx 403 lines1/**
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}
403hooks/delegates.ts 106 lines1/**
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}
106hooks/runs.ts 116 lines1/**
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}
116types/index.d.ts 31 lines1/** 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