/agents-panel: the skills you made, each as a one-click background agent with its own folder, live status, reports and replies.

A Claude Code plugin. Type /agents-panel and a panel opens that lists the skills you made, each as an agent with a ▶ run button.
Pressing run starts that skill as a real agent: a subagent with its own context, running in the background while you keep working. It follows the skill as its procedure, checks its own work and ends with a short report.
~/.claude/skills, ~/.claude/commands, and the project's .claude/skills and .claude/commands).in … line on a row and paste the folder that agent should work in. Skills written for Cowork's "connected folder" or ~/mnt/<name> are told that this folder is that one.period 09-2026, and the next agent you start gets it.You need read access to this repository, and Git signed in to GitHub on your PC (the first install opens a GitHub sign-in window if it isn't).
In a Claude Code terminal session:
/plugin install agents-panel --marketplace CHStormWiggers/agents-panel
Answer y to add the marketplace, then pick the user scope. Once installed at user scope, it also works in the Claude desktop app's Code tab. Start a new chat after installing.
If that line doesn't work for you, use the two-step form:
/plugin marketplace add CHStormWiggers/agents-panel
/plugin install agents-panel@cohedron-plugins
Updates: run claude plugin update agents-panel, then start a new chat.
/agents-panel. The panel opens; type it again to hide it.in session folder on its row, paste the folder path, press save.~/.claude/agents-panel/folders.json (folders) and ~/.claude/agents-panel/runs/ (each agent's last run). Delete that folder to start fresh. Model choices are kept in the plugin's own settings store; click a row's model until it shows inherit to reset it.From the repository root: claude plugin validate . checks the plugin and the marketplace; claude plugin test . runs the tests.
.claude-plugin/plugin.json deliberately has no version: each pushed commit then counts as a new version, so claude plugin update always picks it up. If you add a version, bump it with every change, or updates will report "already at the latest version" and keep the old copy.
hooks/register.tsx 1055 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentsPanelAgent, AgentsPanelRun } from '../types'
5
6const PLUGIN = 'agents-panel'
7const PANE = 'agents'
8const COMMAND = 'agents-panel'
9const MODELS = ['inherit', 'haiku', 'sonnet', 'opus'] as const
10const DOTS = ['#79b8d1', '#e5c07b', '#e0875a', '#c49bbb', '#e06c75', '#9ccc8a', '#8fa9e6', '#d6a35c']
11const ACCENT = '#d97757'
12const SECTION = '#8fa9e6'
13const RUNNING = '#e5c07b'
14const DONE = '#9ccc8a'
15const FAILED = '#e06c75'
16const LOST_AFTER_MS = 30_000
17
18const roster = atom({ plugin: 'agents-panel', key: 'roster' } as const, [])
19const runs = atom({ plugin: 'agents-panel', key: 'runs' } as const, {})
20const models = atom({ plugin: 'agents-panel', key: 'models' } as const, {})
21const brief = atom({ plugin: 'agents-panel', key: 'brief' } as const, '')
22const briefGen = atom({ plugin: 'agents-panel', key: 'briefGen' } as const, 0)
23const view = atom({ plugin: 'agents-panel', key: 'view' } as const, '')
24const folders = atom({ plugin: 'agents-panel', key: 'folders' } as const, {})
25const editing = atom({ plugin: 'agents-panel', key: 'editing' } as const, '')
26const oneDrive = atom({ plugin: 'agents-panel', key: 'oneDrive' } as const, '')
27const sessionId = atom({ plugin: 'agents-panel', key: 'sessionId' } as const, '')
28const dataDir = atom({ plugin: 'agents-panel', key: 'dataDir' } as const, '')
29
30// Agent types registered by this load of the module that the Agent tool does
31// not dispatch yet: a registered type dispatches from the next main-loop turn.
32const fresh = new Set<string>()
33
34// Each agent's last run is kept in a file of its own, so chats share them and
35// never overwrite each other's. A chat writes only the runs it started, and
36// beats a live one's file every half minute; one silent for two is taken as
37// stopped with its chat.
38const BEAT_MS = 30_000
39const STALE_MS = 120_000
40// What this load last wrote per skill, so an unchanged run is not written again.
41const written = new Map<string, string>()
42
43// The person's data, in their own profile rather than the plugin's folder:
44// `<home>/.claude/agents-panel/`, holding `folders.json` (each agent's working
45// folder, by skill name) and `runs/` (each agent's last run).
46const FOLDERS_FILE = 'folders.json'
47const RUNS_FOLDER = 'runs'
48
49const AGENT_RULES = `How you work:
50- Your skill's own rules come first. Where it says to stop and ask the user, or to wait for a confirmation, stop at that point: do not guess past it. Put the question under "Needs your go-ahead" and end your run there.
51- Otherwise you own the task end to end. Plan, act with your tools, check your own results, and keep going until the job is done or you hit a real blocker. Do not stop to ask for confirmation of steps your skill already prescribes.
52- Nobody watches you live and you cannot ask the user questions mid-run. Where something is ambiguous and your skill does not say to stop, take the most reasonable reading (the skill's defaults, today's date, the files you find), note the assumption, and carry on.
53- Stop short of anything irreversible or outward-facing the brief did not explicitly ask for: sending mail or messages, publishing, deleting or overwriting data you did not create, payments. Prepare it, then list it under "Needs your go-ahead".
54- Verify before you report: re-read what you produced and run the checks your skill describes.
55
56Your final message is your report to the user. Keep it short and concrete:
57**Result**: one or two lines: done, partly done, or blocked, and why.
58**What I did**: the key steps.
59**Files**: every file you created or changed, with its full path.
60**Assumptions**: any you made.
61**Needs your go-ahead**: anything left for the user, or "nothing".`
62
63const NO_BRIEF =
64 'No brief was given. Run your skill for its usual purpose, right now, in the current working directory, working out the inputs (period, files, targets) yourself.'
65
66function systemPrompt(agent: AgentsPanelAgent): string {
67 return `You are the "${agent.label}" agent: an autonomous agent built on the ${agent.skill} skill, which is preloaded in your context. That skill is your operating procedure. If you cannot see it, load it first with the Skill tool (skill: "${agent.skill}").
68
69${agent.description ? `What the skill is for: ${agent.description}\n\n` : ''}${AGENT_RULES}`
70}
71
72/** Where the agent works, and how the skill's Cowork terms map onto this PC. */
73function workspace(folder: string, root: string): string {
74 const where = folder
75 ? `Your working folder is: ${folder}
76- It is the folder your skill calls "the connected folder" or its project folder (projectmap). Wherever the skill uses ~/mnt/<name>, $HOME/mnt/<name> or mnt/<name>, use this folder instead: it is that folder.`
77 : `Your working folder is the session's current folder. If your skill names a connected folder, find it under the OneDrive folder below; if you cannot find it for certain, stop and say so.`
78 return `WORKSPACE
79${where}
80- Paths your skill writes as "OneDrive > A > B" are ${root ? `${root}\\A\\B` : 'under the OneDrive folder in your user profile'} on this PC.
81- You run in Claude Code on this computer, not in a Cowork sandbox: there is no device_bash, no ~/mnt and no outputs folder to hand files over through. Use your own shell and file tools, and write results where your skill says, inside the working folder. Keep scratch work in your system temp folder, never in the working folder.
82- Tools your skill assumes (python, openpyxl, LibreOffice) may be missing here. Check first; if one is missing and there is no safe substitute, stop and report it.`
83}
84
85function fallbackPrompt(agent: AgentsPanelAgent, task: string): string {
86 return `You are the "${agent.label}" agent: an autonomous agent built on the ${agent.skill} skill. Start by loading that skill with the Skill tool (skill: "${agent.skill}"), then follow it as your operating procedure.
87
88${AGENT_RULES}
89
90${task}`
91}
92
93/** A stable, unique agent-type name for a skill: readable stem plus a hash of the full name. */
94function slug(skill: string): string {
95 let hash = 2166136261
96 for (const char of skill) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619) >>> 0
97 const stem = skill.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 56) || 'skill'
98 return `${stem}-${hash.toString(36)}`
99}
100
101/** Text the engine would refuse to draw: escape sequences and control characters out, newlines kept. */
102function clean(text: string): string {
103 return text
104 .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '')
105 .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
106 .replace(/\r\n?/g, '\n')
107 .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
108}
109
110function labelOf(skill: string): string {
111 const at = skill.lastIndexOf(':')
112 return at >= 0 ? skill.slice(at + 1) : skill
113}
114
115// Skills the person made in Claude, or, where Claude keeps no record of who
116// made which, the skills in the person's own skills folder.
117const MINE = 'mine'
118const LOCAL = 'local'
119
120function groupTitle(group: string): [string, string] {
121 if (group === MINE) return ['YOURS', 'skills you made']
122 if (group === LOCAL) return ['YOURS', 'your skills folder']
123 return [group.toUpperCase(), '']
124}
125
126function dotOf(skill: string): string {
127 let hash = 0
128 for (const char of skill) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
129 return DOTS[hash % DOTS.length] ?? DOTS[0]!
130}
131
132function since(ms: number): string {
133 const s = Math.max(0, Math.round(ms / 1000))
134 if (s < 60) return `${s}s`
135 const m = Math.floor(s / 60)
136 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
137 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
138}
139
140function ago(ms: number): string {
141 const m = Math.floor(ms / 60_000)
142 if (m < 1) return 'just now'
143 if (m < 60) return `${m}m ago`
144 return `${Math.floor(m / 60)}h ago`
145}
146
147function firstLine(text: string): string {
148 const line = text
149 .split('\n')
150 .map(one => one.replace(/[*_`#>]/g, '').trim())
151 .find(one => one.length > 0 && !/^result:?$/i.test(one))
152 return line ?? ''
153}
154
155/** What the agent's own **Result** line says, when it says it fell short. */
156function shortfall(answer: string | undefined): 'blocked' | 'partly done' | undefined {
157 const line = (answer ?? '').split('\n').find(one => /^\W*result\W*:/i.test(one.trim()))
158 if (!line) return undefined
159 if (/\bblocked\b/i.test(line)) return 'blocked'
160 if (/\bpart(ly|ially)\b/i.test(line)) return 'partly done'
161 return undefined
162}
163
164/** Whether the report leaves something for the person under "Needs your go-ahead". */
165function asksUser(answer: string | undefined): boolean {
166 const lines = (answer ?? '').split('\n')
167 const at = lines.findIndex(one => /needs your go-ahead/i.test(one))
168 if (at < 0) return false
169 const rest = [lines[at]!.replace(/^.*needs your go-ahead\W*/i, '')]
170 for (const line of lines.slice(at + 1)) {
171 if (/^\s*\*\*[^*]+\*\*\s*:/.test(line)) break
172 rest.push(line)
173 }
174 const said = rest.join(' ').replace(/[*_`#>\s.]+/g, ' ').trim()
175 return said.length > 0 && !/^(nothing|none|niets|geen|n\/?a)$/i.test(said)
176}
177
178function isLive(run: AgentsPanelRun | undefined): boolean {
179 return run !== undefined && (run.status === 'starting' || run.status === 'running')
180}
181
182/**
183 * The names of the skills the person made themselves. Claude keeps a
184 * manifest of the account's skills per account and organization, under
185 * `<app data>/Claude/local-agent-mode-sessions/skills-plugin/<account>/<org>/`,
186 * and marks each one's maker: `creatorType` is `user` for the person's own,
187 * `anthropic` for the rest.
188 */
189async function ownSkills($: EngineInterface): Promise<Set<string>> {
190 const appData = await $.env.get('APPDATA')
191 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
192 const roots = [
193 appData && `${appData}/Claude`,
194 home && `${home}/Library/Application Support/Claude`,
195 home && `${home}/.config/Claude`,
196 ].filter((one): one is string => Boolean(one))
197 const names = new Set<string>()
198 for (const root of roots) {
199 const base = `${root}/local-agent-mode-sessions/skills-plugin`
200 const accounts = await $.fs.list(base).catch(() => [])
201 for (const account of accounts.filter(one => one.kind === 'dir')) {
202 const orgs = await $.fs.list(`${base}/${account.name}`).catch(() => [])
203 for (const org of orgs.filter(one => one.kind === 'dir')) {
204 try {
205 const manifest = JSON.parse(await $.fs.read(`${base}/${account.name}/${org.name}/manifest.json`)) as {
206 skills?: { name?: unknown; creatorType?: unknown; enabled?: unknown }[]
207 }
208 for (const skill of manifest.skills ?? []) {
209 if (skill.creatorType === 'user' && skill.enabled !== false && typeof skill.name === 'string') {
210 names.add(skill.name)
211 }
212 }
213 } catch {
214 // No manifest here, or one this version cannot read.
215 }
216 }
217 }
218 }
219 return names
220}
221
222/**
223 * The skills of this session that the person made, with the typeahead's
224 * descriptions. Without Claude's record of who made which (a terminal-only
225 * install), the skills from the person's or project's own skills folder.
226 */
227async function discover($: EngineInterface): Promise<AgentsPanelAgent[]> {
228 const commands = await $.command.list()
229 const describe = new Map(commands.map(command => [command.name, command.description]))
230 let listed: string[] = []
231 try {
232 const usage = await $.session.usage({ breakdown: 'summary' })
233 listed = (usage.context.breakdown?.skills?.skillFrontmatter ?? []).map(skill => skill.name)
234 } catch {
235 listed = []
236 }
237 // The listing can leave skills out past its budget; the typeahead has them all.
238 listed.push(...commands.filter(command => command.source === 'user' || command.source === 'plugin').map(command => command.name))
239 const own = await ownSkills($)
240 // Claude's record counts only where this session has those skills (the
241 // Claude app); a terminal session on the same PC lists the skills folders.
242 const group = listed.some(name => own.has(labelOf(name))) ? MINE : LOCAL
243 const local = new Set(commands.filter(command => command.source === 'user').map(command => command.name))
244 const isOwn = (name: string) => (group === MINE ? own.has(labelOf(name)) : local.has(name))
245 const seen = new Set<string>()
246 const agents: AgentsPanelAgent[] = []
247 for (const name of listed) {
248 const label = labelOf(name)
249 if (name === COMMAND || seen.has(label) || !isOwn(name)) continue
250 seen.add(label)
251 agents.push({ skill: name, label, description: clean(describe.get(name) ?? ''), group })
252 }
253 return agents.sort((a, b) => a.label.localeCompare(b.label))
254}
255
256/**
257 * Registers one agent type per skill: its own system prompt, the skill
258 * preloaded, always in the background. With `isRefresh`, skills that already
259 * have a type keep it, so a type the Agent tool dispatches stays dispatchable.
260 */
261async function enlist($: EngineInterface, isRefresh: boolean): Promise<AgentsPanelAgent[]> {
262 const found = await discover($)
263 const known = new Map((await read($, roster)).map(agent => [agent.skill, agent]))
264 const agents = await Promise.all(
265 found.map(async agent => {
266 const kept = known.get(agent.skill)?.type
267 if (isRefresh && kept) return { ...agent, type: kept }
268 try {
269 const { agent: type } = await $.agent.register({
270 name: slug(agent.skill),
271 description: `Runs the ${agent.skill} skill autonomously, end to end, and reports back.`,
272 prompt: systemPrompt(agent),
273 skills: [agent.skill],
274 background: true,
275 })
276 fresh.add(type)
277 return { ...agent, type }
278 } catch {
279 return { ...agent, type: kept }
280 }
281 }),
282 )
283 await update($, roster, () => agents)
284 return agents
285}
286
287/** The id of this plugin's newest agent with that description that no run holds yet. */
288async function findAgentId($: EngineInterface, description: string): Promise<string | undefined> {
289 const held = new Set(Object.values(await read($, runs)).map(run => run.agentId))
290 const mine = (await $.agent.list()).filter(
291 one => one.spawnedBy === PLUGIN && one.description === description && !held.has(one.id),
292 )
293 return mine[mine.length - 1]?.id
294}
295
296// Data paths are joined with "/", which every platform's file APIs take.
297async function runsDirOf($: EngineInterface): Promise<string> {
298 const data = await read($, dataDir)
299 return data ? `${data}/${RUNS_FOLDER}` : ''
300}
301
302/**
303 * The folders file as it stands: `{}` when there is none yet, undefined when
304 * it cannot be read as a map (so a damaged file is never taken for empty).
305 */
306async function readFoldersFile($: EngineInterface): Promise<Record<string, string> | undefined> {
307 const data = await read($, dataDir)
308 if (!data) return undefined
309 const path = `${data}/${FOLDERS_FILE}`
310 if (!(await $.fs.exists(path).catch(() => false))) return {}
311 try {
312 const kept = JSON.parse(await $.fs.read(path)) as unknown
313 if (!kept || typeof kept !== 'object' || Array.isArray(kept)) return undefined
314 return Object.fromEntries(
315 Object.entries(kept as Record<string, unknown>).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
316 )
317 } catch {
318 return undefined
319 }
320}
321
322/** Each agent's working folder, by skill name, as the person last set them in any chat. */
323async function loadFolders($: EngineInterface) {
324 const onDisk = await readFoldersFile($)
325 if (onDisk) await update($, folders, () => onDisk)
326}
327
328/** Writes this chat's runs that changed since they were last written. */
329async function saveRuns($: EngineInterface) {
330 const dir = await runsDirOf($)
331 const me = await read($, sessionId)
332 if (!dir || !me) return
333 for (const [skill, run] of Object.entries(await read($, runs))) {
334 if (run.sessionId !== me) continue
335 const text = JSON.stringify({ skill, run })
336 if (written.get(skill) === text) continue
337 try {
338 await $.fs.write(`${dir}/${slug(skill)}.json`, text)
339 written.set(skill, text)
340 } catch {
341 // Tried again on the next tick.
342 }
343 }
344}
345
346function isRun(value: unknown): value is AgentsPanelRun {
347 if (!value || typeof value !== 'object') return false
348 const run = value as Record<string, unknown>
349 return typeof run.status === 'string' && typeof run.startedAt === 'number'
350}
351
352/**
353 * Takes in the runs other chats wrote: the newer of each skill's, though a
354 * live run of this chat stays. A live run whose chat stopped beating is shown
355 * as stopped.
356 */
357async function loadRuns($: EngineInterface) {
358 const dir = await runsDirOf($)
359 const me = await read($, sessionId)
360 if (!dir) return
361 const now = await $.clock.now()
362 const files = await $.fs.list(dir).catch(() => [])
363 for (const file of files.filter(one => one.kind === 'file' && one.name.endsWith('.json'))) {
364 let skill: unknown
365 let run: unknown
366 try {
367 ;({ skill, run } = JSON.parse(await $.fs.read(`${dir}/${file.name}`)) as { skill?: unknown; run?: unknown })
368 } catch {
369 continue
370 }
371 if (typeof skill !== 'string' || !isRun(run) || run.sessionId === me) continue
372 const theirs: AgentsPanelRun =
373 isLive(run) && now - (run.beat ?? run.startedAt) > STALE_MS
374 ? { ...run, status: 'stopped', endedAt: run.beat ?? run.startedAt, error: 'its chat closed before it finished' }
375 : run
376 await update($, runs, all => {
377 const ours = all[skill as string]
378 if (ours && ours.sessionId === me && (isLive(ours) || ours.startedAt >= theirs.startedAt)) return all
379 if (ours && JSON.stringify(ours) === JSON.stringify(theirs)) return all
380 return { ...all, [skill as string]: theirs }
381 })
382 }
383}
384
385async function isPaneShown($: EngineInterface): Promise<boolean> {
386 const pane = (await $.ui.panes()).find(one => one.id === PANE)
387 return pane !== undefined && pane.isShown && pane.isPlaced
388}
389
390async function start($: EngineInterface, agent: AgentsPanelAgent, given?: string) {
391 const startedAt = await $.clock.now()
392 const me = await read($, sessionId)
393 // Claim the row in one compare-and-set write, so a double press starts one agent.
394 let holder: AgentsPanelRun | undefined
395 await update($, runs, all => {
396 const current = all[agent.skill]
397 holder = isLive(current) ? current : undefined
398 return holder ? all : { ...all, [agent.skill]: { status: 'starting' as const, startedAt, sessionId: me } }
399 })
400 const busy: AgentsPanelRun | undefined = holder
401 if (busy) {
402 $.ui.toast(
403 busy.sessionId && busy.sessionId !== me
404 ? `${agent.label} is already running in another chat.`
405 : `${agent.label} is already running.`,
406 )
407 return
408 }
409 // The agent works in its skill's folder; one that is not there stops the start.
410 const root = await read($, oneDrive)
411 // The latest folder, even one another chat set a moment ago.
412 await loadFolders($).catch(() => undefined)
413 const folder = folderOf(agent.label, await read($, folders))
414 if (folder && !(await $.fs.exists(folder).catch(() => false))) {
415 const now = await $.clock.now()
416 await update($, runs, all => ({
417 ...all,
418 [agent.skill]: { status: 'failed' as const, startedAt, endedAt: now, error: `folder not found: ${folder}`, sessionId: me },
419 }))
420 $.ui.toast(`${agent.label} could not start: its folder is not there. Click the folder on its row to change it.`)
421 return
422 }
423 // Take the brief in the same way: one run gets it, and the field starts over.
424 // A brief handed in (a reply that restarts the agent) leaves the field alone.
425 let taken = given ?? ''
426 if (given === undefined) {
427 await update($, brief, current => {
428 taken = current
429 return ''
430 })
431 if (taken.trim()) await update($, briefGen, n => n + 1)
432 }
433 const task = `${workspace(folder, root)}\n\nYOUR BRIEF\n${taken.trim() || NO_BRIEF}`
434 const chosen = (await read($, models))[agent.skill]
435 const model = chosen && chosen !== 'inherit' ? chosen : undefined
436 const description = `${agent.label} agent`
437 const cwd = folder || undefined
438
439 // A type the Agent tool dispatches is the agent proper, and its refusal is
440 // final. A type registered since the last turn is not dispatchable yet: a
441 // general-purpose agent loads the skill itself instead.
442 const isTyped = agent.type !== undefined && !fresh.has(agent.type)
443 const spawned = await (isTyped
444 ? $.agent.spawn({ prompt: task, description, subagentType: agent.type, model, cwd })
445 : $.agent.spawn({ prompt: fallbackPrompt(agent, task), description, model, cwd })
446 ).catch((error: unknown) => ({ deny: String(error) }))
447
448 if (spawned.deny !== undefined) {
449 const reason = clean(spawned.deny)
450 const now = await $.clock.now()
451 await update($, runs, all => ({
452 ...all,
453 [agent.skill]: { status: 'failed' as const, startedAt, endedAt: now, error: reason, sessionId: me },
454 }))
455 if (given === undefined && taken.trim()) await update($, brief, current => (current.trim() ? current : taken))
456 $.ui.toast(`${agent.label} could not start: ${reason}`)
457 return
458 }
459 const agentId = spawned.agentId ?? (await findAgentId($, description).catch(() => undefined))
460 await update($, runs, all => {
461 const latest = all[agent.skill]
462 if (latest && latest.startedAt !== startedAt) return all
463 return {
464 ...all,
465 [agent.skill]: { status: 'running' as const, startedAt, agentId, model: spawned.model ?? model, sessionId: me, beat: startedAt },
466 }
467 })
468 $.ui.toast(`${agent.label} started in the background.`)
469 await saveRuns($)
470}
471
472/**
473 * Answers an agent that ended: the reply goes to that same agent, which picks
474 * up with its own context: one that finished is resumed from its transcript.
475 * Only when the message cannot be delivered does a new agent take over,
476 * starting from the report and the answer.
477 */
478async function reply($: EngineInterface, agent: AgentsPanelAgent, typed: string) {
479 const text = typed.trim()
480 if (!text) return
481 const now = await $.clock.now()
482 const me = await read($, sessionId)
483 let ended: AgentsPanelRun | undefined
484 await update($, runs, all => {
485 const run = all[agent.skill]
486 ended = run && !isLive(run) ? run : undefined
487 if (!ended) return all
488 return {
489 ...all,
490 [agent.skill]: {
491 ...ended,
492 status: 'running' as const,
493 startedAt: now,
494 resumedAt: now,
495 beat: now,
496 sessionId: me,
497 endedAt: undefined,
498 previous: ended.answer ?? ended.error,
499 answer: undefined,
500 error: undefined,
501 },
502 }
503 })
504 const before: AgentsPanelRun | undefined = ended
505 if (!before) {
506 $.ui.toast(`${agent.label} is still running; wait for its report.`)
507 return
508 }
509 await update($, view, () => '')
510
511 const message = `The user's answer to your report, relayed by the Agents panel:
512
513${text}
514
515Continue your task from where you stopped, keeping to your skill's rules, and end with a report in the same format.`
516 // A finished agent is resumed from its transcript, listed still or not. A
517 // chat reaches only its own agents: one from an earlier chat is carried on
518 // by a new agent.
519 const agentId = before.agentId
520 const isOurs = !before.sessionId || before.sessionId === me
521 const sent =
522 agentId !== undefined && isOurs
523 ? await $.session
524 .send({ to: { agentId }, text: message })
525 .catch((error: unknown) => ({ isDelivered: false as const, reason: String(error) }))
526 : { isDelivered: false as const, reason: isOurs ? 'its run left no agent id' : 'it ran in another chat' }
527 if (sent.isDelivered) {
528 $.ui.toast(`Sent to ${agent.label}: it carries on in the background.`)
529 await saveRuns($)
530 return
531 }
532
533 // Gone: put the ended run back so a new start can claim the row.
534 await update($, runs, all => ({ ...all, [agent.skill]: before }))
535 await start(
536 $,
537 agent,
538 `An earlier ${agent.label} agent stopped with this report:
539
540${before.answer ?? before.error ?? '(no report)'}
541
542The user answered:
543
544${text}
545
546Pick up from where that run stopped. Check on disk what it already did before you change anything, and do not redo finished steps.`,
547 )
548 $.ui.toast(`${agent.label} could not be reached (${clean(sent.reason)}), so a new agent carries on from its report and your answer.`)
549 await saveRuns($)
550}
551
552async function cycleModel($: EngineInterface, skill: string) {
553 const next = await update($, models, all => {
554 const at = MODELS.indexOf((all[skill] ?? 'inherit') as (typeof MODELS)[number])
555 const model = MODELS[(at + 1) % MODELS.length] ?? 'inherit'
556 return { ...all, [skill]: model }
557 })
558 await $.store.set('models', next)
559}
560
561/** The folder an agent works in, as the person set it, or "" for the session's. */
562function folderOf(label: string, chosen: Record<string, string>): string {
563 return chosen[label] ?? ''
564}
565
566/** A folder as a row shows it: OneDrive-relative, the head cut when it is long. */
567function shortFolder(folder: string, root: string, room: number): string {
568 if (!folder) return 'session folder'
569 const shown = root && folder.startsWith(root) ? `OneDrive${folder.slice(root.length)}` : folder
570 if (shown.length <= room) return shown
571 // Keep the head and as many trailing folders as fit: OneDrive\…\Facturen\01. Automatisering
572 const sep = shown.includes('\\') ? '\\' : '/'
573 const parts = shown.split(/[\\/]/)
574 const head = `${parts[0]}${sep}…`
575 let tail = ''
576 for (let at = parts.length - 1; at > 0; at -= 1) {
577 const next = `${sep}${parts[at]}${tail}`
578 if (head.length + next.length > room) break
579 tail = next
580 }
581 return tail ? `${head}${tail}` : `…${shown.slice(shown.length - room + 1)}`
582}
583
584/** Sets an agent's working folder; empty sets it back to the session's. */
585async function saveFolder($: EngineInterface, label: string, typed: string) {
586 const folder = typed.trim().replace(/^["']|["']$/g, '').replace(/[\\/]+$/, '')
587 if (folder && !(await $.fs.exists(folder).catch(() => false))) {
588 $.ui.toast(`Folder not found: ${folder}`)
589 return
590 }
591 // Merged into the file as it stands now, so a folder another chat set since
592 // is kept; a file that cannot be read is left alone and this chat's map used.
593 const onDisk = await readFoldersFile($)
594 const next = await update($, folders, all => {
595 const copy = { ...(onDisk ?? all) }
596 if (folder) copy[label] = folder
597 else delete copy[label]
598 return copy
599 })
600 const data = await read($, dataDir)
601 if (data) {
602 await $.fs
603 .write(`${data}/${FOLDERS_FILE}`, JSON.stringify(next, null, 2))
604 .catch(() => $.ui.toast('The folder is set for this chat, but could not be saved for later ones.'))
605 }
606 await update($, editing, () => '')
607}
608
609async function showReport($: EngineInterface, skill: string) {
610 await update($, view, () => skill)
611 await $.ui.focus({ requestId: PANE, key: 'back' }).catch(() => undefined)
612}
613
614async function openPanel($: EngineInterface) {
615 const hasRoster = (await read($, roster)).length > 0
616 const agents = hasRoster ? await read($, roster) : await enlist($, false)
617 const opened = await $.ui.open({ id: PANE, title: 'Agents', focus: true })
618 // The other chats' latest runs and folders, without holding the panel up for them.
619 void loadRuns($).catch(() => undefined)
620 void loadFolders($).catch(() => undefined)
621 if (hasRoster) {
622 // Pick up skills added since, without holding the panel up for it.
623 void enlist($, true)
624 .then(() => $.ui.invalidate('ui.render'))
625 .catch(() => undefined)
626 }
627 return { opened, count: agents.length }
628}
629
630/** Settles a live run with no agent id: found in the engine's list, or given up on. */
631async function settleLost($: EngineInterface, skill: string, run: AgentsPanelRun, now: number) {
632 if (now - run.startedAt < LOST_AFTER_MS) return
633 const found = await findAgentId($, `${labelOf(skill)} agent`).catch(() => undefined)
634 await update($, runs, all => {
635 const latest = all[skill]
636 if (!latest || !isLive(latest) || latest.agentId !== undefined || latest.startedAt !== run.startedAt) return all
637 return {
638 ...all,
639 [skill]: found
640 ? { ...latest, status: 'running' as const, agentId: found }
641 : {
642 ...latest,
643 status: 'failed' as const,
644 endedAt: now,
645 error: 'lost track of this run while it started; check the tasks list',
646 },
647 }
648 })
649}
650
651export const register: Register = on => {
652 on('session.start', async ($, e, next) => {
653 const started = await next(e)
654 await $.command.register({
655 name: COMMAND,
656 description: 'Show or hide the Agents panel: the skills you made, each as a one-click background agent',
657 immediate: true,
658 })
659 const kept = await $.store.get('models')
660 if (kept && typeof kept === 'object') {
661 await update($, models, current => ({ ...(kept as Record<string, string>), ...current }))
662 }
663 const profile = await $.env.get('USERPROFILE')
664 const root = (await $.env.get('OneDriveCommercial')) ?? (await $.env.get('OneDrive')) ?? ''
665 await update($, oneDrive, () => root.replace(/[\\/]+$/, ''))
666 // Folders and runs live in the person's profile, shared by every chat.
667 const me = await $.session.id().catch(() => '')
668 await update($, sessionId, () => me)
669 const home = profile ?? (await $.env.get('HOME'))
670 if (home) await update($, dataDir, () => `${home.replace(/[\\/]+$/, '')}/.claude/agents-panel`)
671 await loadFolders($)
672 await loadRuns($).catch(() => undefined)
673 try {
674 await enlist($, false)
675 } catch (error) {
676 $.ui.log(`agents-panel: could not list skills yet (${String(error)}); /agents-panel retries`, { to: 'debug' })
677 }
678
679 // Each second: write this chat's changed runs. While an agent runs: redraw
680 // the clock, beat its file, and every few seconds reconcile with the
681 // engine's own record in case an end went unseen; a run another chat has
682 // going is read again from its file. With none running, read the other
683 // chats' runs each minute and redraw now and then so "ago" stays true.
684 let ticks = 0
685 $.clock.every(1000, async () => {
686 ticks += 1
687 await saveRuns($)
688 const me = await read($, sessionId)
689 const others = Object.values(await read($, runs)).some(
690 run => isLive(run) && run.sessionId !== undefined && run.sessionId !== me,
691 )
692 if ((others && ticks % 5 === 0) || ticks % 60 === 0) await loadRuns($).catch(() => undefined)
693 if (ticks % 60 === 0) await loadFolders($).catch(() => undefined)
694 const current = await read($, runs)
695 const live = Object.entries(current).filter(
696 ([, run]) => isLive(run) && (run.sessionId === undefined || run.sessionId === me),
697 )
698 if (ticks % (BEAT_MS / 1000) === 0 && live.length > 0) {
699 const beat = await $.clock.now()
700 await update($, runs, latest => {
701 const beaten = { ...latest }
702 for (const [skill] of live) {
703 const run = beaten[skill]
704 if (run && isLive(run)) beaten[skill] = { ...run, beat }
705 }
706 return beaten
707 })
708 }
709 if (others) $.ui.invalidate('ui.render')
710 if (live.length === 0) {
711 if (ticks % 30 === 0 && Object.values(current).some(run => run.endedAt !== undefined)) {
712 $.ui.invalidate('ui.render')
713 }
714 return
715 }
716 $.ui.invalidate('ui.render')
717 if (ticks % 5 !== 0) return
718 const listed = new Map((await $.agent.list()).map(agent => [agent.id, agent.status]))
719 const now = await $.clock.now()
720 for (const [skill, run] of live) {
721 // A resumed agent can still read as completed until its new turn starts.
722 if (run.resumedAt !== undefined && now - run.resumedAt < 20_000) continue
723 if (run.agentId === undefined) {
724 await settleLost($, skill, run, now)
725 continue
726 }
727 const status = listed.get(run.agentId)
728 const ended =
729 status === 'failed'
730 ? ('failed' as const)
731 : status === 'killed'
732 ? ('stopped' as const)
733 : status === 'completed'
734 ? ('done' as const)
735 : undefined
736 if (ended === undefined) continue
737 await update($, runs, all => {
738 const latest = all[skill]
739 if (!latest || !isLive(latest) || latest.agentId !== run.agentId) return all
740 return {
741 ...all,
742 [skill]: {
743 ...latest,
744 status: ended,
745 endedAt: now,
746 answer:
747 latest.answer ??
748 (ended === 'done' ? 'The agent finished; its report is in its transcript (open it from the tasks list).' : undefined),
749 },
750 }
751 })
752 }
753 })
754
755 return started
756 })
757
758 // A main-loop turn makes every type registered before it dispatchable.
759 on('turn.start', ($, e, next) => {
760 fresh.clear()
761 return next(e)
762 })
763
764 // The agent types are the panel's to start; they stay out of the model's
765 // listing so forty skills do not crowd every request.
766 on('agent.offer', { agent: /^agents-panel:/ }, () => ({ isOffered: false })).catch(() => ({ isOffered: false }))
767
768 // An agent started from the panel inherits the session's permission mode.
769 // In plan mode it could only write a plan and would report "done": refuse
770 // the start with the reason instead.
771 on('agent.spawn', ($, e, next) =>
772 next.origin.plugin === PLUGIN && e.permissionMode === 'plan'
773 ? { deny: 'plan mode is on; leave it (shift+tab) and press run again' }
774 : next(e),
775 ).catch(($, e, next) => next(e))
776
777 on('command.run', { command: COMMAND }, async $ => {
778 if (await isPaneShown($)) {
779 await $.ui.close({ id: PANE })
780 return { text: 'Agents panel hidden.' }
781 }
782 const { opened, count } = await openPanel($)
783 if (!opened.isPlaced) return { text: 'Agents panel is open but has no room to show; widen the window.' }
784 return { text: `Agents panel opened: ${count} agents.` }
785 })
786
787 // A run ends in the subagent's own turn.complete, carrying its agentId.
788 on('turn.complete', async ($, e, next) => {
789 const result = await next(e)
790 if (e.agentId === undefined) return result
791 const current = await read($, runs)
792 const entry = Object.entries(current).find(([, run]) => run.agentId === e.agentId)
793 if (!entry) return result
794 const [skill, run] = entry
795 const status: AgentsPanelRun['status'] = e.isAborted ? 'stopped' : e.reason === 'answer' ? 'done' : 'failed'
796 const now = await $.clock.now()
797 const answer = clean(e.answer)
798 await update($, runs, all => {
799 const latest = all[skill] ?? run
800 return { ...all, [skill]: { ...latest, status, endedAt: now, answer, model: e.usage?.model ?? latest.model } }
801 })
802 // The report is what another chat answers, so it is written at once.
803 await saveRuns($).catch(() => undefined)
804 const label = labelOf(skill)
805 const asks = status === 'done' && (shortfall(answer) !== undefined || asksUser(answer))
806 const word =
807 status === 'stopped'
808 ? 'was stopped'
809 : status === 'failed'
810 ? 'failed'
811 : (shortfall(answer) ?? (asks ? 'needs your answer' : 'finished'))
812 const button = asks ? 'answer' : 'report'
813 const where = (await isPaneShown($).catch(() => false))
814 ? `press ${button} on its row in the Agents panel`
815 : `type /agents-panel, then press ${button}`
816 $.ui.toast(`${label} ${word} after ${since(now - run.startedAt)}: ${where}.`, { timeoutMs: 8000 })
817 void $.ui.notify(`${label} ${word}`, { title: 'Agents' }).catch(() => undefined)
818 return result
819 })
820
821 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
822 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
823 const width = Math.max(30, e.props.bodyColumns)
824 const allRuns = await read($, runs)
825 const shown = await read($, view)
826 const report = shown ? allRuns[shown] : undefined
827
828 if (shown && report) {
829 const text = clean(report.answer || report.error || 'No report yet.')
830 const agent = (await read($, roster)).find(one => one.skill === shown)
831 const asks = shortfall(report.answer) !== undefined || asksUser(report.answer)
832 // Text fields exist on every surface but mobile.
833 const ReplyInput =
834 e.surface === 'terminal' || e.surface === 'desktop' || e.surface === 'vscode' ? $.ui.resolve(e).Input : undefined
835 const canReply = agent !== undefined && !isLive(report) && ReplyInput !== undefined
836 return (
837 <Box flexDirection="column" width={width}>
838 <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
839 <Text>
840 <Text color={ACCENT}>◆ </Text>
841 <Text bold>{labelOf(shown)}</Text>
842 <Text dimColor> · report</Text>
843 </Text>
844 <Box flexDirection="row">
845 <Button key="copy" label="copy" dimColor onPress={press => void $.ui.copy({ text, surface: press.surface })} />
846 <Button key="back" label="back" onPress={() => update($, view, () => '')} />
847 </Box>
848 </Box>
849 <Markdown key="report" text={text} />
850 {canReply && agent && ReplyInput ? (
851 <Box flexDirection="column" marginTop={1}>
852 <Text color={asks ? RUNNING : SECTION} bold>
853 {asks ? `${agent.label} is waiting for your answer` : `Reply to ${agent.label}`}
854 </Text>
855 <ReplyInput
856 key="reply"
857 placeholder={
858 asks ? 'Your answer; the agent carries on from where it stopped' : 'Ask it to do more; it carries on with what it knows'
859 }
860 submitLabel="send"
861 autoFocus={asks ? true : undefined}
862 onSubmit={typed => reply($, agent, typed)}
863 />
864 </Box>
865 ) : null}
866 </Box>
867 )
868 }
869
870 const agents = await read($, roster)
871 const chosen = await read($, models)
872 const gen = await read($, briefGen)
873 const chosenFolders = await read($, folders)
874 const root = await read($, oneDrive)
875 const editingLabel = await read($, editing)
876 const me = await read($, sessionId)
877 // Text fields exist on every surface but mobile.
878 const Input =
879 e.surface === 'terminal' || e.surface === 'desktop' || e.surface === 'vscode' ? $.ui.resolve(e).Input : undefined
880 const now = await $.clock.now()
881 const liveCount = Object.values(allRuns).filter(isLive).length
882
883 const groups: { group: string; agents: AgentsPanelAgent[] }[] = []
884 for (const agent of agents) {
885 const last = groups[groups.length - 1]
886 if (last && last.group === agent.group) last.agents.push(agent)
887 else groups.push({ group: agent.group, agents: [agent] })
888 }
889
890 const briefField = Input ? (
891 <Box marginBottom={1}>
892 <Input
893 key={`brief-${gen}`}
894 placeholder="Brief for the next agent you start (optional), e.g. period 09-2026"
895 submitLabel="set"
896 onInput={text => update($, brief, () => text)}
897 onSubmit={text => {
898 void update($, brief, () => text)
899 if (text.trim()) $.ui.toast('Brief set: the next agent you start gets it.')
900 }}
901 />
902 </Box>
903 ) : null
904
905 function folderLine(agent: AgentsPanelAgent, id: string) {
906 const folder = folderOf(agent.label, chosenFolders)
907 if (Input && editingLabel === agent.label) {
908 return (
909 <Box key={`folder-edit-${id}`} flexDirection="column">
910 <Input
911 key={`folder-input-${id}`}
912 label="Folder"
913 placeholder="Full path of the folder this agent works in; empty = the session folder"
914 value={folder}
915 submitLabel="save"
916 autoFocus
917 onSubmit={text => saveFolder($, agent.label, text)}
918 />
919 <Box flexDirection="row">
920 {folder ? (
921 <Button key={`folder-clear-${id}`} label="session folder" dimColor onPress={() => saveFolder($, agent.label, '')} />
922 ) : null}
923 <Button key={`folder-cancel-${id}`} label="cancel" dimColor onPress={() => update($, editing, () => '')} />
924 </Box>
925 </Box>
926 )
927 }
928 return (
929 <Button
930 key={`folder-${id}`}
931 plain
932 dimColor
933 onPress={() =>
934 Input ? update($, editing, () => agent.label) : $.ui.toast('Change folders from the desktop app or the terminal.')
935 }
936 >
937 {`in ${shortFolder(folder, root, Math.max(20, width - 4))}`}
938 </Button>
939 )
940 }
941
942 function row(agent: AgentsPanelAgent) {
943 const id = slug(agent.skill)
944 const run = allRuns[agent.skill]
945 const model = chosen[agent.skill] ?? 'inherit'
946 const live = isLive(run)
947 // A run another chat started: it is shown here, and runs or is answered there.
948 const elsewhere = run?.sessionId !== undefined && me !== '' && run.sessionId !== me
949 let status: JSX.Element | null = null
950 if (run && live) {
951 status = (
952 <Text color={RUNNING} wrap="truncate-end">
953 ◌ {run.status === 'starting' ? 'starting' : 'running'}
954 {elsewhere ? ' in another chat' : ''} · {since(now - run.startedAt)}
955 {run.model ? ` · ${run.model}` : ''}
956 </Text>
957 )
958 } else if (run && run.status === 'done') {
959 const short = shortfall(run.answer) ?? (asksUser(run.answer) ? 'needs your answer' : undefined)
960 status = (
961 <Text color={short ? RUNNING : DONE} wrap="truncate-end">
962 {short ? `${short === 'needs your answer' ? '?' : '!'} ${short}` : '✓ done'} in {since((run.endedAt ?? now) - run.startedAt)} · {ago(now - (run.endedAt ?? now))}
963 {elsewhere ? ' · earlier chat' : ''}
964 {run.answer ? <Text dimColor> · {firstLine(clean(run.answer))}</Text> : ''}
965 </Text>
966 )
967 } else if (run) {
968 status = (
969 <Text color={FAILED} wrap="truncate-end">
970 ✗ {run.status === 'stopped' ? 'stopped' : 'failed'}
971 {elsewhere ? ' · earlier chat' : ''}
972 {run.error ? ` · ${clean(run.error)}` : run.answer ? ` · ${firstLine(clean(run.answer))}` : ''}
973 </Text>
974 )
975 }
976
977 return (
978 <Box key={`row-${id}`} flexDirection="column" marginBottom={1}>
979 <Box flexDirection="row" justifyContent="space-between">
980 <Box flexDirection="row" flexShrink={1}>
981 <Text color={live ? RUNNING : dotOf(agent.skill)}>● </Text>
982 <Text bold wrap="truncate-end">
983 {agent.label}
984 </Text>
985 <Text> </Text>
986 <Button key={`model-${id}`} plain dimColor onPress={() => cycleModel($, agent.skill)}>
987 {model}
988 </Button>
989 </Box>
990 <Box flexDirection="row" flexShrink={0}>
991 {run && !live && (run.answer || run.error) ? (
992 shortfall(run.answer) !== undefined || asksUser(run.answer) ? (
993 <Button key={`view-${id}`} label="answer" variant="primary" onPress={() => showReport($, agent.skill)} />
994 ) : (
995 <Button key={`view-${id}`} label="report" dimColor onPress={() => showReport($, agent.skill)} />
996 )
997 ) : null}
998 <Button
999 key={`run-${id}`}
1000 label={live ? '◌ running' : '▶ run'}
1001 dimColor={live}
1002 onPress={() => start($, agent)}
1003 />
1004 </Box>
1005 </Box>
1006 <Text dimColor wrap="truncate-end">
1007 {agent.description || 'No description.'}
1008 </Text>
1009 {folderLine(agent, id)}
1010 {status}
1011 </Box>
1012 )
1013 }
1014
1015 return (
1016 <Box flexDirection="column" width={width}>
1017 <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
1018 <Text>
1019 <Text color={ACCENT}>◆ </Text>
1020 <Text bold>Agents</Text>
1021 <Text dimColor> from your skills</Text>
1022 </Text>
1023 <Text dimColor>
1024 {liveCount > 0 ? `${liveCount} running · ` : ''}
1025 {agents.length} defined
1026 </Text>
1027 </Box>
1028 {briefField}
1029 {agents.length === 0 ? <Text dimColor>No skills of your own found yet. In the Claude app, the skills you make in Claude show up here; in a terminal, the skills in ~/.claude/skills and this project's .claude/skills do. Run /agents-panel again once the session has started.</Text> : null}
1030 {groups.map(({ group, agents: members }) => {
1031 const [title, where] = groupTitle(group)
1032 return (
1033 <Box key={`group-${slug(group)}`} flexDirection="column">
1034 <Box marginBottom={1}>
1035 <Text>
1036 <Text bold color={SECTION}>
1037 {title}
1038 </Text>
1039 <Text dimColor>
1040 {' '}
1041 {where ? `${where} · ` : ''}
1042 {members.length}
1043 </Text>
1044 </Text>
1045 </Box>
1046 {members.map(row)}
1047 </Box>
1048 )
1049 })}
1050 <Text dimColor>click ▶ run to start one · click a model or folder to change it · /agents-panel to hide</Text>
1051 </Box>
1052 )
1053 })
1054}
1055types/index.d.ts 55 lines1export type AgentsPanelAgent = {
2 /** The skill's name as /skills lists it (`anthropic-skills:docx`). */
3 skill: string
4 /** What the panel calls it: the skill name without its plugin prefix. */
5 label: string
6 /** The agent type registered for it, `agents-panel:<slug>`, once registered. */
7 type?: string
8 description: string
9 /** Section key: `user`, `project`, `plugin:<name>`, `built-in`, ... */
10 group: string
11}
12
13export type AgentsPanelRunStatus = 'starting' | 'running' | 'done' | 'failed' | 'stopped'
14
15export type AgentsPanelRun = {
16 agentId?: string
17 status: AgentsPanelRunStatus
18 startedAt: number
19 endedAt?: number
20 model?: string
21 answer?: string
22 error?: string
23 /** When the person last replied and the agent took up its task again. */
24 resumedAt?: number
25 /** The report the person replied to. */
26 previous?: string
27 /** The chat (session id) that started it and keeps its file. */
28 sessionId?: string
29 /** When that chat last said a live run is still going. */
30 beat?: number
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'agents-panel': {
36 roster: AgentsPanelAgent[]
37 runs: Record<string, AgentsPanelRun>
38 models: Record<string, string>
39 brief: string
40 briefGen: number
41 view: string
42 /** Each agent's working folder by skill label; absent is the session's folder. */
43 folders: Record<string, string>
44 /** The skill label whose folder is being edited, or "". */
45 editing: string
46 /** The OneDrive root on this PC. */
47 oneDrive: string
48 /** This chat's session id. */
49 sessionId: string
50 /** The person's agents-panel data folder (folders.json, runs/), shared by every chat. */
51 dataDir: string
52 }
53 }
54}
55