Specialist subagents (researcher, analyst, architect, developer, QA automation, DevOps, SRE, security, code review, docs) that follow one shared set of working…

Una camada de subagentes especialistas que trabajan con las mismas reglas, en Claude Code, opencode, Codex, Antigravity CLI y Gemini CLI (obsoleto).
rules/RULES.md y se instalan una sola vez en la memoria global de cada agente. Las leen la conversación principal y todos los subagentes, así que una instalación limpia sigue tus reglas desde el primer mensaje./nursery encadena los subagentes para una feature, un bug, un cambio de infraestructura o un incidente.Se instala con kit, como el resto de las herramientas.
npm install -g @kikedealba/nursery
nursery setup
nursery setup conecta nursery con los agentes que encuentre (--agents claude,codex elige cuáles, --agents all los fuerza todos):
nursery de este repositorio, que es su propio marketplace, y el bloque de reglas en ~/.claude/CLAUDE.md./nursery como comando.agy) recibe el plugin nursery, y el bloque de reglas en ~/.gemini/GEMINI.md, que comparte con Gemini CLI.Gemini CLI está obsoleto: el 19 de mayo de 2026 Google anunció Antigravity CLI como su sucesor. nursery lo mantiene por ahora. Se detecta por el binario gemini o por ~/.gemini/extensions; la carpeta ~/.gemini sola no basta, porque agy también la crea.
| Agente | Subagentes | Reglas |
|---|---|---|
| Claude Code | plugin nursery | ~/.claude/CLAUDE.md |
| opencode | ~/.config/opencode/agents/*.md | ~/.config/opencode/AGENTS.md |
| Codex | ~/.codex/agents/*.toml | ~/.codex/AGENTS.md |
| Antigravity CLI | plugin nursery instalado con agy plugin install (se copia a ~/.gemini/config/plugins/nursery/; trae los diez agentes, /nursery y rules/nursery.md con trigger: always_on) | bloque compartido en ~/.gemini/GEMINI.md con Gemini CLI |
| Gemini CLI (obsoleto) | extensión nursery (agents/*.md, /nursery y sus hooks) y política ~/.gemini/policies/nursery.toml para el modo plan | ~/.gemini/GEMINI.md |
El bloque de reglas va entre <!-- kit:nursery:begin --> y <!-- kit:nursery:end -->; el resto del archivo no se toca. Si el archivo ya tiene las reglas sin esos marcadores (copiadas a mano antes de nursery), el bloque no se escribe para no duplicarlas. nursery setup --replace-legacy quita esa sección, deja una copia en <archivo>.legacy-backup y escribe el bloque.
Gemini CLI y Antigravity CLI escriben el mismo bloque en ~/.gemini/GEMINI.md, así que solo hay uno. nursery uninstall lo quita únicamente cuando ninguno de los dos sigue instalado. Si agy no está en el PATH, el paso del plugin sale como no disponible.
nursery check dice si el bloque de cada agente está al día, falta o choca con reglas sin marcadores. nursery uninstall quita el plugin, los subagentes, los bloques y el registro en kit.
Antes de instalar en los agentes, nursery setup registra nursery en el registro de kit (nursery.json en el directorio del registro, o en KIT_REGISTRY_DIR si está definido), igual que el resto de las herramientas. El manifiesto declara las capacidades agents.subagents y agents.rules; nursery no emite eventos ni se suscribe a los de otras herramientas. nursery uninstall borra el registro.
nursery capabilities --json imprime en una línea el sobre de kit con { name, version, envelope, capabilities, emits }; sin --json lo muestra en texto. Con --json, un comando desconocido imprime un sobre de error (ok: false, código UNKNOWN_COMMAND) y sale con código 2.
Sin nursery setup, el plugin se instala desde Claude Code (2.1.275 o posterior) con:
/plugin install nursery --marketplace KikeDeAlba/nursery
El plugin trae los subagentes, /nursery y un panel en vivo de subagentes. Las reglas no viajan en el plugin, porque un plugin no puede escribir en CLAUDE.md: para ellas hace falta nursery setup.
| Pieza | Qué es | Cuándo cuesta contexto |
|---|---|---|
| Descripción de cada subagente | Cuándo delegarle (≤ 300 caracteres) | Siempre, unos 650 tokens entre los diez |
| Cuerpo de cada subagente | Rol, pasos, límites y formato de entrega | Solo dentro del subagente, al llamarlo |
skills/nursery | /nursery: elige la cadena y pasa entregas entre agentes | Nada hasta que el usuario lo escribe |
hooks/ | Panel de subagentes (ver «Panel de subagentes») | Nunca: no agrega nada al prompt |
| Bloque de reglas | rules/RULES.md en la memoria global | Siempre, unos 1,400 tokens, en la conversación y en cada subagente |
| Agente | Para qué | Herramientas | Modelo / esfuerzo |
|---|---|---|---|
researcher | Confirma o refuta supuestos con fuentes antes de planear; no diseña ni edita | lectura, Bash, web, skills | sonnet / medium |
analyst | Historias, criterios de aceptación, lectura de tickets y minutas | lectura, Bash, skills | sonnet / medium |
architect | Diseño de soluciones, alternativas y ADRs | lectura, Bash, web, skills | opus / high |
developer | Feature o bugfix acotado en su worktree, hasta el MR | todas | claude-opus-5-5 / medium |
qa-automation | Plan de pruebas, unitarias, integración y e2e (Playwright) | todas | sonnet / medium |
devops | Terraform por manifests, OIDC, GitLab CI y GitHub Actions | todas | sonnet / medium |
sre | Incidentes, logs, métricas, alarmas y runbooks | lectura, Bash, web, skills | sonnet / medium |
security | IAM, red, secretos, dependencias y PCI | lectura, Bash, web | sonnet / high |
code-reviewer | Revisión de diff o MR antes de mezclar | lectura, Bash | sonnet / high |
documenter | README, inkwell, Confluence, runbooks | lectura, escritura, Bash, skills | sonnet / medium |
Ningún subagente precarga skills: las usa (atl, inkwell, gaze, recap) solo cuando las necesita. Todos terminan con la misma entrega de máximo 15 líneas (resultado, cambios, evidencia, pendiente y siguiente agente), para que lo que vuelve a la conversación principal sea corto. maxTurns corta los bucles.
En Antigravity, tools se traduce a sus nombres (view_file, grep_search, run_command…), model es siempre inherit y effort y maxTurns no aplican. Delega con invoke_subagent; las partes en paralelo del mismo repo van con Workspace: "branch". A diferencia de Gemini CLI, el modo plan de agy sí permite los subagentes del plugin.
Workspace: "branch" crea un worktree en ~/.gemini/antigravity-cli/worktrees/<conversación>/subagent-… con una rama propia que parte del HEAD, y agy no lo limpia: tras integrar la parte, se mezcla su rama en la de la tarea y se borran su worktree y su rama.
En agy -p (sin interfaz) las confirmaciones de herramientas se rechazan, así que el uso headless necesita reglas permissions.allow para las herramientas que usan los subagentes, en ~/.gemini/antigravity-cli/settings.json o donde lo documente agy. Conviene permitir solo lo necesario.
En opencode, Codex y Gemini, model no se traduce y se hereda el de la sesión; effort pasa a model_reasoning_effort en Codex, y tools a permisos o nombres de herramienta en cada uno.
El modo plan de Gemini CLI solo deja usar sus propios subagentes, y una extensión no puede habilitar otros. Por eso nursery setup instala la política de usuario ~/.gemini/policies/nursery.toml, que permite en modo plan a researcher, analyst, architect, security, code-reviewer y sre. En modo plan los subagentes de nursery no tienen shell: solo herramientas de lectura, búsqueda y web; el resto de su trabajo lo hacen fuera del modo plan. Para planear conviene el modelo auto: con él, general.plan.modelRouting planea con Pro y ejecuta con Flash; con un modelo fijo no hay ese cambio. Los agentes tienen variantes por herramienta: subagents/*.md (generado) conserva los bloques ::: agent y es lo que instalan opencode, Codex y Gemini, cada uno con su parte. En Gemini un turno es una respuesta del modelo, así que el researcher tiene 60 turnos en vez de 30 y una guía para lanzar juntas las llamadas independientes y entregar a tiempo. Si ya había una política distinta, nursery setup la respalda en nursery.toml.bak antes de reemplazarla. nursery uninstall quita la política solo si no se editó; si se editó, la deja y lo avisa.
/nursery en Gemini CLIEn Gemini el flujo de nursery no vive en GEMINI.md, que lleva las mismas reglas que opencode y Codex, sino en /nursery <pedido> y en los hooks de la extensión. Funciona en cualquier modo de aprobación, también en yolo, y no usa el modo plan. Fuera de /nursery, Gemini trabaja normal: los hooks no hacen nada si la sesión no empezó el flujo.
| Fase | Qué pasa |
|---|---|
| Explorar | Gemini invoca al researcher si el pedido no es trivial y pregunta solo las dudas que cambian el plan. Tiene 8 lecturas propias; tras la entrega del researcher le quedan 3 como máximo |
| Propuesta | Gemini la presenta con ask_user: una pregunta con header «Propuesta», la propuesta completa y las opciones «Aceptar» y «Corregir» |
| Aceptación | El usuario acepta con «Aceptar» en el diálogo o escribiendo «acepto» (también «acepto el plan», «aceptado», «apruebo» o «aprobado»). «Sí», «ok» o «dale» no aceptan: cuentan como corrección. Si cierra el diálogo sin responder, vuelve a explorar y Gemini espera |
| Corrección | Si el usuario corrige, con el diálogo o con un mensaje, vuelve a explorar con 3 lecturas y propone de nuevo, las veces que haga falta |
| Ejecución | Ya aceptada, cada paso va a su agente con invoke_agent, en paralelo los independientes; Gemini solo escribe archivos .md |
| Validación | Antes de cerrar tienen que pasar qa-automation y code-reviewer; si faltan, el hook le pide lanzarlos, hasta 2 reintentos. Si siguen faltando, el plan se cierra y la bitácora dice «sin validar tras 2 reintentos» |
Antes de aceptar, todo lo que no sea leer, preguntar o delegar a agentes de lectura se bloquea: escribir archivos, el shell (también mientras corre un subagente), invocar a developer, devops, qa-automation o documenter, entrar o salir del modo plan, cualquier herramienta desconocida y las de MCP que no sean de lectura. Una herramienta MCP cuenta como lectura si su nombre en el servidor (mcp_context.tool_name) lleva un verbo de lectura (get, search, list, read, describe, view, fetch, find, show, comments, sites, count, check o inspect) y ninguno que pueda escribir o ejecutar (create, update, delete, call, merge, push, query, exec, save…); gasta lecturas del tope como las demás. La clasificación es por verbos: un nombre con un verbo de lectura y uno de escritura que no esté en la lista pasaría como lectura. Si el estado del flujo no se puede leer, solo se permiten lecturas, preguntas y agentes de lectura. También se bloquea leer más allá del tope y presentar una propuesta con otro formato. Al negar, el motivo dice qué hacer en su lugar.
Ya aceptado el plan, el shell del hilo principal queda libre, pero usarlo, igual que escribir un .md fuera del directorio de planes, exige la validación de qa-automation y code-reviewer antes de cerrar.
Si el archivo de estado de la sesión no se puede leer, el hook niega escribir, el shell, los agentes de trabajo y las herramientas MCP de escritura hasta que se vuelva a lanzar /nursery o se desactive con NURSERY_FLOW=off. Si el hook mismo falla o pasa de su timeout, Gemini deja pasar la herramienta: es una limitación de Gemini CLI, no algo que el hook pueda evitar.
Cada /nursery deja una bitácora del plan en ~/.gemini/nursery/plans/<proyecto>/<fecha>-<slug>.md, con el pedido, cada propuesta, las correcciones, los agentes y la validación. Solo tu usuario puede leer el estado y la bitácora (archivos 0600, directorios 0700). NURSERY_PLANS_DIR cambia el directorio base, y NURSERY_FLOW=off desactiva el flujo sin desinstalar nada.
No funciona en modo headless (gemini -p): ahí ask_user se niega, así que no hay forma de aceptar la propuesta.
El hook corre antes de cada herramienta en todas las sesiones de Gemini, también fuera de /nursery; sin flujo activo responde en unos 40 ms y deja pasar todo.
La sección «Delegación» de las reglas está en la memoria global, así que también aplica en modo plan:
qa-automation y code-reviewer, y security si se toca IAM, red, secretos o pagos.Ejemplo: «las conexiones a la base se quedan abiertas y el proxy no las corta».
| Paso | Agente | Qué hace | Depende de |
|---|---|---|---|
| 1 | sre | Confirma con métricas del proxy y de la base qué conexiones quedan abiertas | — |
| 2a | developer | Pool con máximo, timeout de inactividad, vida por conexión y cierre en el shutdown | 1 |
| 2b | devops | Timeout de clientes inactivos y tope de conexiones del RDS Proxy en el manifest | 1 |
| 3 | security | Revisa el cambio de infraestructura | 2b |
| 4 | qa-automation | Prueba de fuga de conexiones | 2a |
| 5 | code-reviewer | Revisa todo antes de los MR | 3, 4 |
2a y 2b corren en paralelo.
Cuando dos partes en paralelo van en el mismo repo:
feature/<n>-<parte> que nace de la rama de la tarea.feature/<n>, corre el gate y las borra.Sigue siendo una instrucción para el modelo, no un mecanismo. Si alguna vez no delega, /nursery fuerza la cadena.
Al lanzarse un subagente se abre el panel «Subagentes». Es un mod de Claude Code: un módulo de hooks en hooks/register.tsx, declarado en hooks/hooks.json. Se instala con el plugin, sin nada extra.
Subagentes
● developer implementa endpoint 1m12s 14 herramientas
✓ Read src/api/users.ts
… Bash npm test
» Las pruebas pasan, falta el lint
✓ code-reviewer revisa diff 0m48s 6 herramientas
Por cada agente muestra:
nursery:developer se ve como developer) y la descripción de la tarea;● corriendo, ✓ terminado, ✗ falló, ■ abortado;herr. si el panel es angosto);… en curso, ✓ hecha, ✗ falló; el comando de Bash, el archivo de Read, Edit o Write, el patrón de Grep o Glob) o la primera línea de lo que dijo (»).Los agentes terminados quedan atenuados en una sola línea, sin actividad. Un agente que lleva más de 30 s corriendo (contados desde que se lanzó o desde su última reanudación) y falta dos veces seguidas en la lista de agentes de Claude Code se da por terminado (✓). Si la consulta de la lista falla entre dos ausencias, no cuenta y no borra la anterior: las dos ausencias siguen siendo seguidas. Volver a lanzar un agente terminado solo lo reabre si es una reanudación del mismo agente.
El panel tiene un fondo oscuro fijo (#1e1e1e) que ocupa todo su alto, con un borde redondeado y colores propios, iguales con cualquier tema de Claude Code.
Los subagentes que lanza otro subagente salen sangrados bajo su padre. El panel muestra todos los subagentes, no solo los de nursery.
Cómo se usa:
/nursery-panel lo abre siempre.● developer · 1m12s · Bash npm test.El panel no cuesta contexto: no agrega nada al prompt del modelo. Requiere una versión de Claude Code con soporte de mods (hooks modules); se probó con Claude Code 2.1.302 (claude --version).
/nursery/nursery PP-123
/nursery el job de deploy de codi-webhook falla en qa
Elige la cadena (feature, bug, infraestructura, incidente o revisión), llama a cada subagente con la entrega del anterior, hace las preguntas pendientes en un solo bloque y no hace nada destructivo ni de producción sin confirmación.
Los agentes se escriben en src/agents/*.md. Los fragmentos compartidos de src/conventions/ entran con @include <nombre>:
| Fragmento | Contenido | Lo usan |
|---|---|---|
stack-node | Node 24, NestJS hexagonal, TypeORM, gate local | developer, qa-automation |
stack-terraform | Manifests, OIDC, bootstrap, nombres, gate | devops, security |
ci | Plantillas de GitLab, ramas y tags por ambiente, GitHub Actions | devops |
observability | Datadog, CloudWatch, logs estructurados | sre |
branch | Rama asignada y entrega como parte de una tarea en paralelo | developer, devops, qa-automation |
handoff | Formato de la entrega | todos |
pnpm build:agents (nursery build) genera agents/*.md, la variante de Claude Code, y subagents/*.md, la portable con los bloques ::: agent que instalan las demás herramientas. Los dos van versionados porque el marketplace instala desde git.
pnpm install
pnpm build:agents
pnpm typecheck
pnpm test
Las pruebas fallan si:
agents/ o subagents/ no coinciden con sus fuentes;rules/RULES.md;rules/antigravity.md pierde su contenido esperado;commands/ en la raíz: Claude Code lo carga entero en el plugin, así que el comando de Gemini vive en gemini/commands/nursery.md.El límite de caracteres del bloque de reglas también cubre a Antigravity: comparte el bloque con Gemini.
Para validar el plugin de Antigravity sin instalarlo:
agy plugin validate <raíz> --strict --json
nursery setup ya lo corre sobre el plugin generado antes de instalarlo, y no instala si falla.
Para probar el panel de subagentes (hooks/):
claude plugin validate .
claude plugin test .
claude --plugin-dir .
claude plugin validate . valida el plugin, incluido el módulo de hooks.claude plugin test . corre las pruebas de hooks/*.test.ts. Son aparte de pnpm test, que corre test/*.spec.ts; el sufijo distinto evita que claude plugin test intente cargar las pruebas de node.claude --plugin-dir . carga el plugin desde el checkout, sin instalarlo.Evals con el SDK de Antigravity (paquete google-antigravity, <https://antigravity.google/docs/sdk/overview/>).
MIT
hooks/register.tsx 560 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, AgentStatus, EngineInterface, Register, RenderNode, Timer } from 'claude-code'
3
4import type { NurseryActivity, NurseryAgent, NurseryAgentStatus } from '../types'
5
6const PANE = 'nursery-panel'
7const TITLE = 'Subagentes'
8const MAX_AGENTS = 30
9const MAX_ACTIVITY = 5
10const TOOL_LABEL_MAX = 80
11const TEXT_LABEL_MAX = 120
12const TYPE_WIDTH = 15
13const TICK_MS = 1000
14const RECONCILE_EVERY = 5
15const MAX_ENDED_EARLY = 50
16const ABSENT_GRACE_MS = 30_000
17const WIDE_COLUMNS = 80
18const FRAME_ROWS = 2
19const MIN_PANE_ROWS = 4
20const MIN_PANE_COLUMNS = 44
21const FRAME_COLUMNS = 4
22const NURSERY_PREFIX = 'nursery:'
23
24const agents = atom({ plugin: 'nursery', key: 'agents' } as const, [])
25const clockNow = atom({ plugin: 'nursery', key: 'now' } as const, 0)
26const isPaneOffered = atom({ plugin: 'nursery', key: 'isPaneOffered' } as const, false)
27
28const TOOL_DETAIL: Readonly<Record<string, string>> = {
29 Bash: 'command',
30 Read: 'file_path',
31 Edit: 'file_path',
32 Write: 'file_path',
33 Grep: 'pattern',
34 Glob: 'pattern',
35 Agent: 'description',
36}
37
38const PATH_TOOLS = new Set(['Read', 'Edit', 'Write'])
39
40const STATUS_FROM_LIST: Readonly<Record<AgentStatus, NurseryAgentStatus>> = {
41 pending: 'running',
42 running: 'running',
43 waiting: 'running',
44 idle: 'completed',
45 completed: 'completed',
46 failed: 'failed',
47 killed: 'killed',
48}
49
50const STATUS_GLYPH: Readonly<Record<NurseryAgentStatus, string>> = {
51 running: '●',
52 completed: '✓',
53 failed: '✗',
54 killed: '■',
55}
56
57const STATUS_COLOR: Readonly<Record<NurseryAgentStatus, string>> = {
58 running: 'claude',
59 completed: 'success',
60 failed: 'error',
61 killed: 'inactive',
62}
63
64const PANE_COLOR = {
65 background: '#1e1e1e',
66 text: '#d4d4d4',
67 muted: '#8a8a8a',
68 accent: '#e5a050',
69 error: '#f07070',
70 success: '#6ccf8a',
71 border: '#5a5a5a',
72} as const
73
74const PANE_STATUS_COLOR: Readonly<Record<NurseryAgentStatus, string>> = {
75 running: PANE_COLOR.accent,
76 completed: PANE_COLOR.success,
77 failed: PANE_COLOR.error,
78 killed: PANE_COLOR.muted,
79}
80
81const ACTIVITY_GLYPH: Readonly<Record<NurseryActivity['state'], string>> = {
82 running: '…',
83 done: '✓',
84 failed: '✗',
85}
86
87type Placed = { agent: NurseryAgent; depth: number }
88
89type Ending = { status: NurseryAgentStatus; endedAt: number }
90
91const ESCAPE_SEQUENCE = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|[@-Z\\-_])/g
92const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
93
94let ticker: Timer | undefined
95let ticks = 0
96let cwd: string | undefined
97const endedEarly = new Map<string, Ending>()
98let missedOnce = new Set<string>()
99
100const sanitize = (text: string): string =>
101 text.replace(ESCAPE_SEQUENCE, '').replace(/\t/g, ' ').replace(CONTROL, '')
102
103const firstLine = (text: string): string =>
104 text
105 .split(/\r\n|\r|\n/)
106 .map(line => sanitize(line).trim())
107 .find(line => line.length > 0) ?? ''
108
109const clip = (text: string, max: number): string =>
110 text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text
111
112const relativeTo = (path: string, root: string | undefined): string => {
113 if (root === undefined || root === '') return path
114 const prefix = root.endsWith('/') ? root : `${root}/`
115
116 return path.startsWith(prefix) ? path.slice(prefix.length) : path
117}
118
119const toolLabel = (
120 tool: string,
121 input: Readonly<Record<string, unknown>>,
122 root: string | undefined,
123): string => {
124 const field = TOOL_DETAIL[tool]
125 const raw = field === undefined ? undefined : input[field]
126 const value = typeof raw === 'string' ? raw : ''
127 const detail = firstLine(PATH_TOOLS.has(tool) ? relativeTo(value, root) : value)
128
129 return clip(detail === '' ? tool : `${tool} ${detail}`, TOOL_LABEL_MAX)
130}
131
132const formatElapsed = (ms: number): string => {
133 const total = Math.max(0, Math.floor(ms / 1000))
134 const hours = Math.floor(total / 3600)
135 const minutes = Math.floor((total % 3600) / 60)
136 const seconds = total % 60
137
138 return hours > 0
139 ? `${hours}h${String(minutes).padStart(2, '0')}m`
140 : `${minutes}m${String(seconds).padStart(2, '0')}s`
141}
142
143const shortType = (type: string): string =>
144 type.startsWith(NURSERY_PREFIX) ? type.slice(NURSERY_PREFIX.length) : type
145
146const isRunning = (agent: NurseryAgent): boolean => agent.status === 'running'
147
148const elapsedOf = (agent: NurseryAgent, now: number): string =>
149 formatElapsed((agent.endedAt ?? now) - agent.startedAt)
150
151const withActivity = (agent: NurseryAgent, activity: NurseryActivity): NurseryAgent => ({
152 ...agent,
153 activity: [...agent.activity, activity].slice(-MAX_ACTIVITY),
154})
155
156const byStatusThenStart = (a: NurseryAgent, b: NurseryAgent): number =>
157 Number(isRunning(b)) - Number(isRunning(a)) || a.startedAt - b.startedAt
158
159const arrange = (list: readonly NurseryAgent[]): Placed[] => {
160 const ids = new Set(list.map(agent => agent.id))
161 const children = new Map<string | undefined, NurseryAgent[]>()
162
163 for (const agent of list) {
164 const parent = agent.parentId !== undefined && ids.has(agent.parentId) ? agent.parentId : undefined
165 children.set(parent, [...(children.get(parent) ?? []), agent])
166 }
167
168 const walk = (parent: string | undefined, depth: number): Placed[] =>
169 [...(children.get(parent) ?? [])]
170 .sort(byStatusThenStart)
171 .flatMap(agent => [{ agent, depth }, ...walk(agent.id, depth + 1)])
172
173 return walk(undefined, 0)
174}
175
176const quietly = async (work: () => Promise<unknown>): Promise<void> => {
177 try {
178 await work()
179 } catch {
180 return
181 }
182}
183
184const isWatched = async ($: EngineInterface, agentId: string | undefined): Promise<boolean> => {
185 if (agentId === undefined) return false
186 try {
187 return (await read($, agents)).some(agent => agent.id === agentId)
188 } catch {
189 return false
190 }
191}
192
193const change = ($: EngineInterface, agentId: string, fn: (agent: NurseryAgent) => NurseryAgent) =>
194 update($, agents, list => list.map(agent => (agent.id === agentId ? fn(agent) : agent)))
195
196const toolsText = (count: number, isWide: boolean): string =>
197 isWide ? `${count} ${count === 1 ? 'herramienta' : 'herramientas'}` : `${count} herr.`
198
199const rememberEnding = (agentId: string, ending: Ending): void => {
200 endedEarly.delete(agentId)
201 endedEarly.set(agentId, ending)
202 for (const stale of endedEarly.keys()) {
203 if (endedEarly.size <= MAX_ENDED_EARLY) break
204 endedEarly.delete(stale)
205 }
206}
207
208const reconcile = async ($: EngineInterface): Promise<void> => {
209 const listed = await $.agent.list()
210 const statusById = new Map(listed.map(info => [info.id, STATUS_FROM_LIST[info.status]] as const))
211 const now = await $.clock.now()
212 const marked = missedOnce
213 const list = await update($, agents, current =>
214 current.map(agent => {
215 if (!isRunning(agent)) return agent
216 const status = statusById.get(agent.id)
217 if (status === undefined) {
218 const isPastGrace = now - (agent.resumedAt ?? agent.startedAt) > ABSENT_GRACE_MS
219
220 return marked.has(agent.id) && isPastGrace ? { ...agent, status: 'completed', endedAt: now } : agent
221 }
222
223 return status === 'running' ? agent : { ...agent, status, endedAt: now }
224 }),
225 )
226 missedOnce = new Set(list.filter(agent => isRunning(agent) && !statusById.has(agent.id)).map(agent => agent.id))
227}
228
229const stopTicking = (): void => {
230 ticker?.cancel()
231 ticker = undefined
232 missedOnce = new Set()
233}
234
235const tick = async ($: EngineInterface): Promise<void> => {
236 if (!(await read($, agents)).some(isRunning)) {
237 stopTicking()
238 return
239 }
240 ticks += 1
241 if (ticks % RECONCILE_EVERY === 0) await quietly(() => reconcile($))
242 const now = await $.clock.now()
243 await update($, clockNow, () => now)
244}
245
246const keepTicking = ($: EngineInterface): void => {
247 if (ticker !== undefined) return
248 ticker = $.clock.every(TICK_MS, () => {
249 void quietly(() => tick($))
250 })
251}
252
253const sessionCwd = async ($: EngineInterface): Promise<string | undefined> => {
254 if (cwd !== undefined) return cwd
255 try {
256 cwd = await $.session.cwd()
257 } catch {
258 return undefined
259 }
260
261 return cwd
262}
263
264const fromInfo = (info: AgentInfo, now: number): NurseryAgent => ({
265 id: info.id,
266 type: info.type,
267 description: info.description,
268 ...(info.parentId === undefined ? {} : { parentId: info.parentId }),
269 status: STATUS_FROM_LIST[info.status],
270 startedAt: now,
271 ...(STATUS_FROM_LIST[info.status] === 'running' ? {} : { endedAt: now }),
272 toolCount: 0,
273 activity: [],
274})
275
276const seed = async ($: EngineInterface): Promise<void> => {
277 const listed = await $.agent.list()
278 if (listed.length === 0) return
279 const now = await $.clock.now()
280 const list = await update($, agents, current => {
281 const known = new Set(current.map(agent => agent.id))
282 const fresh = listed.filter(info => !known.has(info.id)).map(info => fromInfo(info, now))
283
284 return [...current, ...fresh].slice(-MAX_AGENTS)
285 })
286 if (list.some(isRunning)) keepTicking($)
287}
288
289const textOf = (content: readonly { type: string; [field: string]: unknown }[]): string =>
290 content
291 .filter(block => block.type === 'text' && typeof block['text'] === 'string')
292 .map(block => block['text'] as string)
293 .join('\n')
294
295const viewAgent = async ($: EngineInterface, agentId: string): Promise<void> => {
296 const viewed = await $.ui.view({ agentId })
297 if (viewed.deny !== undefined) $.ui.toast(`No se pudo abrir el subagente: ${viewed.deny}`)
298}
299
300const offerPane = async ($: EngineInterface): Promise<void> => {
301 if (await read($, isPaneOffered)) return
302 await update($, isPaneOffered, () => true)
303 await $.ui.open({ id: PANE, title: TITLE })
304}
305
306const statusOfTurn = (reason: string): NurseryAgentStatus =>
307 reason === 'answer' ? 'completed' : reason === 'aborted' ? 'killed' : 'failed'
308
309export const register: Register = on => {
310 on('session.start', async ($, e, next) => {
311 cwd = e.cwd
312 missedOnce = new Set()
313 await quietly(() =>
314 $.command.register({ name: 'nursery-panel', description: 'Abre el panel en vivo de los subagentes' }),
315 )
316 await quietly(() => seed($))
317
318 return next(e)
319 })
320
321 on('command.run', { command: 'nursery-panel' }, async $ => {
322 const opened = await $.ui.open({ id: PANE, title: TITLE })
323
324 return {
325 text: opened.isPlaced
326 ? 'Panel de subagentes abierto.'
327 : `El panel de subagentes espera lugar: ${opened.reason}`,
328 }
329 })
330
331 on('agent.spawn', async ($, e, next) => {
332 const started = await next(e)
333 const agentId = started.agentId
334 if (agentId === undefined) return started
335
336 await quietly(async () => {
337 const now = await $.clock.now()
338 const early = e.resume === undefined ? endedEarly.get(agentId) : undefined
339 endedEarly.delete(agentId)
340 if (e.resume !== undefined) missedOnce.delete(agentId)
341 const list = await update($, agents, list => {
342 const previous = list.find(agent => agent.id === agentId)
343 const base: NurseryAgent = previous ?? {
344 id: agentId,
345 type: e.subagentType,
346 description: e.description,
347 ...(e.parentAgentId === undefined ? {} : { parentId: e.parentAgentId }),
348 status: 'running',
349 startedAt: now,
350 toolCount: 0,
351 activity: [],
352 }
353 const { endedAt: _ended, ...rest } = base
354 const agent: NurseryAgent =
355 e.resume !== undefined
356 ? { ...rest, status: 'running', resumedAt: now }
357 : early !== undefined
358 ? { ...rest, ...early }
359 : previous !== undefined && !isRunning(previous)
360 ? previous
361 : { ...rest, status: 'running' }
362
363 return [...list.filter(one => one.id !== agentId), agent].slice(-MAX_AGENTS)
364 })
365 if (list.some(isRunning)) keepTicking($)
366 await offerPane($)
367 })
368
369 return started
370 }).catch(($, e, next) => next(e))
371
372 on('tool.call', async ($, e, next) => {
373 const agentId = e.agentId
374 if (agentId === undefined || !(await isWatched($, agentId))) return next(e)
375
376 const tool = String(e.tool)
377 const callId = e.tool_use_id
378 await quietly(async () => {
379 const label = toolLabel(tool, e as unknown as Record<string, unknown>, await sessionCwd($))
380 await change($, agentId, agent =>
381 withActivity(agent, { kind: 'tool', label, state: 'running', id: callId }),
382 )
383 })
384
385 const finish = (state: NurseryActivity['state']) =>
386 quietly(() =>
387 change($, agentId, agent => ({
388 ...agent,
389 toolCount: agent.toolCount + 1,
390 activity: agent.activity.map(one =>
391 one.kind === 'tool' && one.id === callId ? { ...one, state } : one,
392 ),
393 })),
394 )
395
396 let ran: Awaited<ReturnType<typeof next>>
397 try {
398 ran = await next(e)
399 } catch (error) {
400 await finish('failed')
401 throw error
402 }
403 await finish(ran.deny !== undefined || ran.isError === true ? 'failed' : 'done')
404
405 return ran
406 }).catch(($, e, next) => next(e))
407
408 on('session.append', async ($, e, next) => {
409 const stored = await next(e)
410 const agentId = e.agentId
411
412 if (e.message.type === 'assistant' && (await isWatched($, agentId)) && agentId !== undefined) {
413 await quietly(async () => {
414 const line = firstLine(textOf(e.message.content))
415 if (line === '') return
416 await change($, agentId, agent =>
417 withActivity(agent, { kind: 'text', label: clip(line, TEXT_LABEL_MAX), state: 'done' }),
418 )
419 })
420 }
421
422 return stored
423 }).catch(($, e, next) => next(e))
424
425 on('turn.complete', async ($, e, next) => {
426 const done = await next(e)
427 const agentId = e.agentId
428
429 if (agentId !== undefined) {
430 await quietly(async () => {
431 const ending: Ending = { status: statusOfTurn(e.reason), endedAt: await $.clock.now() }
432 if (await isWatched($, agentId)) {
433 await change($, agentId, agent => ({ ...agent, ...ending }))
434 } else {
435 rememberEnding(agentId, ending)
436 }
437 })
438 }
439
440 return done
441 }).catch(($, e, next) => next(e))
442
443 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
444 const { Box, Button, Text } = $.ui.resolve(e)
445 const list = await read($, agents)
446 await read($, clockNow)
447 const now = await $.clock.now()
448 if (list.some(isRunning)) keepTicking($)
449 const bodyRows = Math.max(
450 MIN_PANE_ROWS,
451 e.props.scroll.bodyRows > 0 ? e.props.scroll.bodyRows : (e.viewport?.rows ?? 24) - 4,
452 )
453 const room = bodyRows - FRAME_ROWS
454 const columns = Math.max(MIN_PANE_COLUMNS, e.props.bodyColumns) - FRAME_COLUMNS
455 const isWide = columns >= WIDE_COLUMNS
456 const frame = (...content: RenderNode[]) => (
457 <Box flexDirection="column" width="100%" height={bodyRows} backgroundColor={PANE_COLOR.background}>
458 <Box
459 flexDirection="column"
460 flexGrow={1}
461 borderStyle="round"
462 borderColor={PANE_COLOR.border}
463 paddingX={1}
464 >
465 <Text bold color={PANE_COLOR.text}>
466 {TITLE}
467 </Text>
468 {content}
469 </Box>
470 </Box>
471 )
472
473 if (list.length === 0) {
474 return frame(<Text color={PANE_COLOR.muted}>Ningún subagente en esta sesión.</Text>)
475 }
476
477 const lines = arrange(list).flatMap(({ agent, depth }) => {
478 const indent = ' '.repeat(depth)
479 const isDone = !isRunning(agent)
480 const type = shortType(agent.type)
481 const isNursery = type !== agent.type
482 const tail = `${elapsedOf(agent, now).padStart(6)} ${toolsText(agent.toolCount, isWide).padStart(isWide ? 16 : 8)}`
483 const descriptionWidth = Math.max(8, columns - indent.length - TYPE_WIDTH - tail.length - 5)
484 const description = clip(firstLine(agent.description), descriptionWidth).padEnd(descriptionWidth)
485 const typeColor = isDone ? PANE_COLOR.muted : isNursery ? PANE_COLOR.accent : PANE_COLOR.text
486 const row = (
487 <Button key={`agent-${agent.id}`} plain onPress={() => void quietly(() => viewAgent($, agent.id))}>
488 {indent}
489 <Text color={PANE_STATUS_COLOR[agent.status]}>{STATUS_GLYPH[agent.status]}</Text>{' '}
490 <Text bold color={typeColor}>
491 {clip(type, TYPE_WIDTH).padEnd(TYPE_WIDTH)}
492 </Text>{' '}
493 <Text color={isDone ? PANE_COLOR.muted : PANE_COLOR.text}>{description}</Text>
494 <Text color={PANE_COLOR.muted}>{` ${tail}`}</Text>
495 </Button>
496 )
497 const activity = isDone
498 ? []
499 : agent.activity.map(one => {
500 const color =
501 one.kind === 'tool' && one.state === 'done' ? PANE_COLOR.muted : PANE_COLOR.text
502
503 return (
504 <Text wrap="truncate" color={color}>
505 {indent}
506 {' '}
507 <Text color={one.state === 'failed' ? PANE_COLOR.error : color}>
508 {one.kind === 'text' ? '»' : ACTIVITY_GLYPH[one.state]}
509 </Text>{' '}
510 {one.label}
511 </Text>
512 )
513 })
514
515 return [row, ...activity]
516 })
517
518 const visible = lines.length > room - 1 ? lines.slice(0, Math.max(1, room - 2)) : lines
519 const hidden = lines.length - visible.length
520
521 return frame(
522 ...visible,
523 ...(hidden > 0 ? [<Text color={PANE_COLOR.muted}>{`+${hidden} más`}</Text>] : []),
524 )
525 })
526
527 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
528 if (e.props.hasSurvey) return next(e)
529
530 const running = (await read($, agents)).filter(isRunning)
531 if (running.length === 0) return next(e)
532
533 const panes = await $.ui.panes()
534 if (panes.some(pane => pane.id === PANE && pane.isPlaced)) return next(e)
535
536 keepTicking($)
537 const { Box, Text } = $.ui.resolve(e)
538 await read($, clockNow)
539 const now = await $.clock.now()
540 const shown = running.slice(0, Math.max(1, e.props.maxRows))
541
542 return (
543 <Box flexDirection="column">
544 {shown.map(agent => {
545 const current =
546 [...agent.activity].reverse().find(one => one.kind === 'tool')?.label ??
547 firstLine(agent.description)
548 const line = `${shortType(agent.type)} · ${elapsedOf(agent, now)} · ${current}`
549
550 return (
551 <Text wrap="truncate">
552 <Text color={STATUS_COLOR.running}>{STATUS_GLYPH.running}</Text> {line}
553 </Text>
554 )
555 })}
556 </Box>
557 )
558 })
559}
560types/index.d.ts 32 lines1export type NurseryActivityKind = 'tool' | 'text'
2
3export type NurseryActivityState = 'running' | 'done' | 'failed'
4
5export type NurseryActivity = {
6 kind: NurseryActivityKind
7 label: string
8 state: NurseryActivityState
9 id?: string
10}
11
12export type NurseryAgentStatus = 'running' | 'completed' | 'failed' | 'killed'
13
14export type NurseryAgent = {
15 id: string
16 type: string
17 description: string
18 parentId?: string
19 status: NurseryAgentStatus
20 startedAt: number
21 endedAt?: number
22 resumedAt?: number
23 toolCount: number
24 activity: NurseryActivity[]
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 nursery: { agents: NurseryAgent[]; now: number; isPaneOffered: boolean }
30 }
31}
32