Relay between two local sessions: one plans, the other builds, and they hand work back and forth on their own.

Conecta dos sesiones locales de Claude Code: una planifica y la otra construye. Se pasan el trabajo sin que tengas que intervenir.
La planificadora escribe un paso dentro de <handoff>…</handoff>. Al acabar su turno, relevo lo envía a la constructora. La constructora lo ejecuta y responde con otro <handoff> que lleva su informe. El ciclo sigue hasta que la planificadora emite <done>…</done>, se llega a maxRounds o lo pausas tú.
Elige una de estas dos formas. Si combinas dos, por ejemplo la instalación y --plugin-dir, los hooks se cargan dos veces y cada handoff sale duplicado.
Instalarlo desde el marketplace (recomendado). La raíz de este repositorio trae .claude-plugin/marketplace.json:
claude plugin marketplace add melandres8/claude-mods
claude plugin install relevo@claude-mods
Para trabajar sobre una copia local, pasa la ruta del clon en lugar de melandres8/claude-mods. Claude Code lee el plugin desde la carpeta misma. Tras editarlo, /reload-plugins lo recarga en la sesión abierta.
Variable de entorno. En ~/.claude/settings.json, el de usuario y no el del proyecto:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/ruta/a/claude-mods/relevo" } }
En los dos casos relevo se carga en todas las sesiones. La banda solo aparece en las que tienen un rol.
Abre dos sesiones de Claude Code en el proyecto. Llamamos A a la que planifica y B a la que construye.
/relevo. Se abre el panel con tres pasos: 1 el rol, 2 la otra sesión (elígela en Sesiones o escribe su nombre o id en Peer; ↻ recarga la lista) y 3 Conectar./relevo builder <id-de-A>. El panel de B (/relevo) muestra la misma petición con un botón Aceptar. Acepta solo si ese id es de verdad tu otra sesión. Al aceptar, B responde y A muestra ✓ junto al peer.Con el relevo en marcha, el panel muestra el último handoff enviado (↑) y el último recibido (↓), un handoff retenido si lo hay, y los botones Pausa o Reanudar, Quitar y Cerrar. En el móvil solo muestra el estado y los botones.
La banda sobre el prompt resume el estado en una línea: un punto del color del estado, el rol en una etiqueta de color (planner y builder tienen colores distintos), el peer, las rondas como barra ▰▰▰▱▱▱▱▱▱▱ 3/10 y quién tiene el turno, por ejemplo turno de B · hace 12s. Los colores salen del tema: verde activo, amarillo en pausa, rojo error, violeta terminado. ⚙ abre el panel.
Lo mismo sin panel:
/relevo planner <peer> ` Como <peer> sirve el sessionId de B, que /relevo status muestra en B, o el nombre con el que B aparece en ListAgents (también nombre [ref] si dos sesiones comparten nombre). La respuesta trae ya la línea para B, con el id de A: /relevo builder <id-de-A>`. /relevo builder <id-de-A> ` Si B ejecuta la línea antes de que llegue la petición de A, la petición sale de B hacia A, y A la acepta igual, con /relevo planner <id-de-B> o con Aceptar en su panel. Si las dos peticiones se cruzan, acepta en una sola de las dos sesiones. Al enlazarse, la petición sobrante se descarta. Si las dos aceptan a la vez, las rondas se descartan como no verificadas: ejecuta /relevo off` en las dos y enlázalas de nuevo desde una. /relevo goal Añadir paginación al endpoint /users con tests ``| Comando | Qué hace | |
|---|---|---|
/relevo | Abre el panel. | |
/relevo planner <peer> | Esta sesión planifica. Si <peer> ya pidió enlazar como builder, acepta su petición. | |
/relevo builder <peer> | Esta sesión construye. Si <peer> ya pidió enlazar como planner, acepta su petición. | |
/relevo goal <texto> | Solo en la planificadora: arranca el ciclo. | |
/relevo pause / resume | Corta o reanuda el envío automático. resume envía el handoff retenido y pasa al modelo la ronda que llegó en pausa. | |
/relevo status | Muestra rol, peer, ronda, último envío, el id de esta sesión (con rol o sin él) y la petición de enlace pendiente, si la hay. | |
/relevo off | Quita el rol. Una sesión enlazada rechaza `/relevo planner\ | builder hasta que ejecutas /relevo off`. |
maxRounds (por defecto 10, en /config), en las dos sesiones: al llegar al límite, relevo se pausa, guarda el <handoff> pendiente y avisa con un toast. /relevo resume lo envía y concede otras maxRounds rondas. Si la planificadora reanuda pasado el límite, la constructora también se pausa al llegar al suyo y hay que reanudarla allí.<done> en la planificadora termina el ciclo, aunque la misma respuesta traiga también un <handoff>.<handoff> queda retenido y relevo se pausa. Cuando el peer acepta, /relevo resume lo envía.<handoff> queda retenido, y /relevo resume lo reenvía con la misma ronda./relevo planner|builder rechazan como peer el id de esta sesión./relevo <rol> <id> o con Aceptar en el panel. relevo no responde nunca a una petición que nadie aceptó, así que quien la envía no sabe nada de esta sesión ni de su peer. Una sesión ya enlazada ignora las peticiones nuevas.peer con plugin relevo). Un texto igual escrito por un modelo con SendMessage, un mensaje peer-send-message o una entrada de buzón sin verificar se descarta con un toast, y no llega al modelo.ronda 0 no la reinicia ni se salta maxRounds./relevo status), y /relevo resume se la pasa al modelo.<handoff> igual al que acaba de recibir o al último que envió.<handoff>, relevo la quita antes de poner la suya, así que el peer la lee una sola vez.nombre [ref], la forma que acepta SendMessage. /relevo planner|builder nombre [ref] también la acepta.[relevo ronda N · de planner|builder · <nonce>]. El modelo la lee sin el nonce, [relevo ronda N · de planner|builder], así que no lo ve nunca.<handoff> que cita una de las dos sigue siendo un handoff.<handoff> se lee de answer en turn.complete, que es el texto final del turno. Si ese texto no trae el bloque, relevo lee el último mensaje del asistente con $.session.messages().$.state, así que aguantan una recarga en caliente.session.receive. Los de relevo llegan con origen peer y plugin relevo. Según los tipos, ese nombre de plugin lo declara el motor que envía, no es una prueba de identidad. Por eso relevo lo usa solo como filtro, y la garantía la dan la confirmación de la persona y el nonce. Según los tipos de este build llega como un turno nuevo, o dentro del turno en curso si la otra sesión está ocupada. "Entregado" significa que está en cola, no que se haya leído.claude plugin validate .
claude plugin test .hooks/register.tsx 1005 lines1import { atom, read, update } from 'claude-code'
2import type {
3 Color,
4 ElementConstructor,
5 EngineInterface,
6 Register,
7 SessionReceiveOrigin,
8 TextProps,
9 Timer,
10} from 'claude-code'
11
12import type { RelevoLink, RelevoPeer, RelevoRole, RelevoTurn } from '../types'
13import {
14 address,
15 ago,
16 bar,
17 extractTag,
18 header,
19 linkAsk,
20 linkOk,
21 looksLikeHeader,
22 looksLikeLink,
23 parseHeader,
24 parseLink,
25 preview,
26 sameText,
27 stripHeader,
28} from './relay'
29
30const role = atom({ plugin: 'relevo', key: 'role' } as const, null)
31const peer = atom({ plugin: 'relevo', key: 'peer' } as const, null)
32const round = atom({ plugin: 'relevo', key: 'round' } as const, 0)
33const paused = atom({ plugin: 'relevo', key: 'paused' } as const, false)
34const isDone = atom({ plugin: 'relevo', key: 'isDone' } as const, false)
35const lastHandoffAt = atom({ plugin: 'relevo', key: 'lastHandoffAt' } as const, null)
36const lastReceivedAt = atom({ plugin: 'relevo', key: 'lastReceivedAt' } as const, null)
37const lastReceived = atom({ plugin: 'relevo', key: 'lastReceived' } as const, null)
38const lastSent = atom({ plugin: 'relevo', key: 'lastSent' } as const, null)
39const pending = atom({ plugin: 'relevo', key: 'pending' } as const, null)
40const cap = atom({ plugin: 'relevo', key: 'cap' } as const, null)
41const goal = atom({ plugin: 'relevo', key: 'goal' } as const, null)
42const turn = atom({ plugin: 'relevo', key: 'turn' } as const, null)
43const link = atom({ plugin: 'relevo', key: 'link' } as const, 'none')
44const lastError = atom({ plugin: 'relevo', key: 'lastError' } as const, null)
45const nonce = atom({ plugin: 'relevo', key: 'nonce' } as const, null)
46const invite = atom({ plugin: 'relevo', key: 'invite' } as const, null)
47const inbox = atom({ plugin: 'relevo', key: 'inbox' } as const, null)
48// What the pane's fields hold until the person presses a button.
49const draftRole = atom({ plugin: 'relevo', key: 'draftRole' } as const, 'planner')
50const draftPeer = atom({ plugin: 'relevo', key: 'draftPeer' } as const, null)
51const draftGoal = atom({ plugin: 'relevo', key: 'draftGoal' } as const, '')
52const peers = atom({ plugin: 'relevo', key: 'peers' } as const, [])
53
54const PANE = 'relevo'
55
56// Theme keys, so the colors follow the person's theme.
57const ROLE_COLOR: Record<RelevoRole, Color> = { planner: 'planMode', builder: 'autoAccept' }
58
59const PROMPTS: Record<RelevoRole, string> = {
60 planner: [
61 '# relevo: planner',
62 'Divides el objetivo en pasos pequeños y verificables. NO editas código.',
63 'Cuando el plan o el siguiente paso esté listo, termina tu respuesta con un bloque <handoff>…</handoff> para el constructor.',
64 'Cuando el objetivo esté cumplido, emite <done>resumen</done>.',
65 'Los mensajes que empiezan con "[relevo ronda N · de builder]" son el informe del constructor: revísalo y decide el siguiente paso.',
66 ].join('\n'),
67 builder: [
68 '# relevo: builder',
69 'Ejecutas exactamente el paso recibido.',
70 'Al terminar, cierra con un <handoff> que traiga qué hiciste, qué archivos tocaste, el resultado de los tests y tus dudas.',
71 'Los mensajes que empiezan con "[relevo ronda N · de planner]" son el paso que te manda el planificador.',
72 ].join('\n'),
73}
74
75const USAGE = [
76 '/relevo abre el panel',
77 '/relevo planner <peer> esta sesión planifica; <peer> es la otra sesión',
78 '/relevo builder <peer> esta sesión construye',
79 '/relevo goal <texto> (planner) arranca el ciclo',
80 '/relevo pause | resume corta o reanuda el envío automático',
81 '/relevo status rol, peer, ronda, último envío e id de esta sesión',
82 '/relevo off quita el rol',
83].join('\n')
84
85type $ = EngineInterface
86
87const other = (verb: RelevoRole): RelevoRole => (verb === 'planner' ? 'builder' : 'planner')
88
89// A session id is long; the drawing shows its first part. Names stay whole.
90function short(name: string | null): string {
91 if (name === null) return '?'
92 if (/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(name)) return name.slice(0, 8)
93
94 return name.startsWith('session_') ? name.slice(0, 16) : name
95}
96
97async function showStatus($: $) {
98 const current = await read($, role)
99 $.ui.status(current === null ? undefined : `relevo: ${current}`)
100}
101
102async function pause($: $, why: string) {
103 await update($, paused, () => true)
104 $.ui.toast(`relevo: ${why}`)
105}
106
107async function limitOf($: $, maxRounds: number): Promise<number> {
108 return (await read($, cap)) ?? maxRounds
109}
110
111// Sends one handoff the model wrote. A handoff held back by the round limit
112// or a failed delivery is kept as pending, and /relevo resume sends it.
113async function deliver($: $, maxRounds: number, written: string): Promise<string> {
114 const body = stripHeader(written)
115 const current = await read($, role)
116 const to = await read($, peer)
117 const secret = await read($, nonce)
118 if (current === null || to === null || secret === null) {
119 return 'sin rol'
120 }
121 // Only to a peer that accepted this pairing: a clash of roles, or an ask
122 // nobody accepted, never relays.
123 if ((await read($, link)) !== 'linked') {
124 await update($, pending, () => body)
125 await pause($, `sin enlace con ${short(to)}; el handoff queda retenido. Cuando acepte, /relevo resume lo envía.`)
126
127 return 'sin enlace'
128 }
129 // The cap holds on both sides, so neither one alone can loop.
130 const limit = await limitOf($, maxRounds)
131 const n = current === 'planner' ? (await read($, round)) + 1 : Math.max(1, await read($, round))
132 if (n > limit) {
133 await update($, pending, () => body)
134 await pause($, `llegó a ${limit} rondas; en pausa (/relevo resume para seguir).`)
135
136 return 'límite de rondas'
137 }
138
139 let reason: string | null = null
140 try {
141 const sent = await $.session.send({ to: address(to), text: `${header(n, current, secret)}\n\n${body}` })
142 if (!sent.isDelivered) reason = sent.reason
143 } catch (error) {
144 reason = error instanceof Error ? error.message : String(error)
145 }
146 if (reason !== null) {
147 await update($, pending, () => body)
148 await update($, lastError, () => `no se pudo entregar: ${reason}`)
149 await pause($, `no se pudo entregar a ${to}: ${reason}. En pausa.`)
150
151 return reason
152 }
153
154 await update($, round, () => n)
155 await update($, lastSent, () => body)
156 await update($, pending, () => null)
157 await update($, lastError, () => null)
158 await update($, turn, () => 'peer')
159 const now = await $.clock.now()
160 await update($, lastHandoffAt, () => now)
161
162 return `ronda ${n} enviada`
163}
164
165async function reset($: $) {
166 await update($, round, () => 0)
167 await update($, cap, () => null)
168 await update($, paused, () => false)
169 await update($, isDone, () => false)
170 await update($, lastSent, () => null)
171 await update($, pending, () => null)
172 await update($, inbox, () => null)
173 await update($, lastHandoffAt, () => null)
174 await update($, lastError, () => null)
175 await update($, turn, () => null)
176}
177
178type Tone = Color
179type Live = {
180 current: RelevoRole
181 to: string | null
182 n: number
183 limit: number
184 isPaused: boolean
185 link: RelevoLink
186 turn: RelevoTurn
187 state: string
188 tone: Tone
189}
190
191// What the band and the pane show on their status line; null without a role.
192async function liveStatus($: $, maxRounds: number): Promise<Live | null> {
193 const current = await read($, role)
194 if (current === null) {
195 return null
196 }
197 const to = await read($, peer)
198 const at = await read($, lastHandoffAt)
199 const isPaused = await read($, paused)
200 const linked = await read($, link)
201 const holder = await read($, turn)
202 const failed = await read($, lastError)
203 const [state, tone]: [string, Tone] = (await read($, isDone))
204 ? ['terminado', 'merged']
205 : failed !== null && isPaused
206 ? ['en pausa · error', 'error']
207 : isPaused
208 ? ['en pausa', 'warning']
209 : linked === 'waiting'
210 ? ['esperando enlace', 'suggestion']
211 : holder === 'self'
212 ? ['trabajando aquí', 'claude']
213 : holder === 'peer' && at !== null
214 ? [`turno de ${short(to)} · hace ${ago((await $.clock.now()) - at)}`, 'success']
215 : current === 'planner'
216 ? ['esperando objetivo', 'suggestion']
217 : ['esperando paso', 'suggestion']
218
219 return {
220 current,
221 to,
222 n: await read($, round),
223 limit: await limitOf($, maxRounds),
224 isPaused,
225 link: linked,
226 turn: holder,
227 state,
228 tone,
229 }
230}
231
232// One row: a dot in the state's color, the role as a chip, the peer, the
233// rounds as cells when the row is wide enough, and the state.
234function liveLine(Text: ElementConstructor<TextProps>, live: Live | null, columns: number) {
235 if (live === null) {
236 return (
237 <Text wrap="truncate-end">
238 <Text color="claude" bold>
239 relevo
240 </Text>
241 <Text dimColor> · sin rol</Text>
242 </Text>
243 )
244 }
245 const color = ROLE_COLOR[live.current]
246 const cells = columns >= 90 ? bar(live.n, live.limit, 10) : null
247
248 return (
249 <Text wrap="truncate-end">
250 <Text color={live.tone}>● </Text>
251 <Text color="claude" bold>
252 relevo{' '}
253 </Text>
254 <Text backgroundColor={color} color="inverseText" bold>
255 {` ${live.current.toUpperCase()} `}
256 </Text>
257 <Text color={color}> ⇄ </Text>
258 <Text bold>{short(live.to)}</Text>
259 {live.link === 'linked' ? <Text color="success"> ✓</Text> : null}
260 <Text dimColor> · ronda </Text>
261 {cells !== null ? <Text color={color}>{cells.done}</Text> : null}
262 {cells !== null ? <Text dimColor>{cells.left} </Text> : null}
263 <Text bold>
264 {live.n}/{live.limit}
265 </Text>
266 <Text dimColor> · </Text>
267 <Text color={live.tone}>{live.state}</Text>
268 </Text>
269 )
270}
271
272type AgentListing = {
273 sections?: { kind: string; rows: { name?: string; ref?: string; id?: string; status?: string }[] }[]
274 notes?: { kind: string; text: string }[]
275}
276
277// The other sessions ListAgents lists, this one left out: it names itself in
278// a note ("This session is <name> [<ref>]"), not by its session id. Empty on
279// a deny, an error or a listing without sections.
280async function listPeers($: $): Promise<RelevoPeer[]> {
281 try {
282 const called = await $.tool.call({ tool: 'ListAgents' })
283 if (called.deny !== undefined || called.isError === true) {
284 return []
285 }
286 const listing = called.result as AgentListing | undefined
287 const self = await $.session.id()
288 const selfNames = (listing?.notes ?? []).flatMap(note => {
289 const match = /this session is (\S+)(?: \[(\w+)\])?/i.exec(note.text)
290
291 return match === null ? [] : [match[1], match[2]].filter(name => name !== undefined)
292 })
293 const isSelf = (row: { name?: string; ref?: string; id?: string }) =>
294 (row.id !== undefined && (row.id === self || self.startsWith(row.id))) ||
295 (row.ref !== undefined && self.startsWith(row.ref)) ||
296 selfNames.includes(row.name ?? '') ||
297 selfNames.includes(row.ref ?? '')
298
299 return (listing?.sections ?? [])
300 .filter(section => !/self|this/i.test(section.kind))
301 .flatMap(section => section.rows)
302 .filter(row => row.name !== undefined && !isSelf(row))
303 .map(row => ({ name: row.name ?? '', ref: row.ref, status: row.status }))
304 } catch {
305 return []
306 }
307}
308
309async function loadPeers($: $) {
310 const found = await listPeers($)
311 await update($, peers, () => found)
312}
313
314// Opens the pane; undefined when it is drawn, so the command prints nothing.
315// The peer list loads beside it, not awaited: ListAgents may ask permission.
316async function openPane($: $): Promise<string | undefined> {
317 void loadPeers($)
318 const opened = await $.ui.open({ id: PANE, title: 'relevo', focus: true, closeOnEscape: true })
319
320 return opened.isPlaced ? undefined : `el panel espera: ${opened.reason}`
321}
322
323async function sendQuiet($: $, to: string, text: string): Promise<boolean> {
324 try {
325 return (await $.session.send({ to: address(to), text })).isDelivered
326 } catch {
327 return false
328 }
329}
330
331// One function per /relevo verb, each returning the text the command shows.
332// The command parses its arguments and calls these; a pane can call them too.
333
334// Sets the role: the person's own act, the only one that binds a peer or
335// sets the link's nonce. When the peer already asked for this pairing, it
336// accepts that ask and acks with its nonce; otherwise it asks the peer with
337// a fresh nonce, and the peer's person accepts there.
338async function setRole($: $, verb: RelevoRole, to: string): Promise<string> {
339 const self = await $.session.id()
340 const target = to.trim()
341 if (target === self) {
342 return 'el peer no puede ser esta misma sesión.'
343 }
344 // A live link is changed only on purpose: a command from an old toast
345 // would otherwise reset it under a new nonce the peer does not hold.
346 if ((await read($, role)) !== null && (await read($, link)) === 'linked') {
347 return `ya enlazada con ${await read($, peer)}; /relevo off para cambiar.`
348 }
349 const asked = await read($, invite)
350 const accepting = asked !== null && asked.id === target && other(asked.from) === verb ? asked : null
351 await update($, role, () => verb)
352 await update($, peer, () => target)
353 await reset($)
354 await update($, goal, () => null)
355 await update($, lastReceived, () => null)
356 await update($, lastReceivedAt, () => null)
357 await update($, invite, () => null)
358 await showStatus($)
359
360 if (accepting !== null) {
361 await update($, nonce, () => accepting.nonce)
362 await update($, link, () => 'linked')
363 await sendQuiet($, target, linkOk(verb, accepting.nonce))
364
365 return `enlazada con ${target}; esta sesión es ${verb.toUpperCase()}.`
366 }
367
368 const fresh = crypto.randomUUID()
369 await update($, nonce, () => fresh)
370 const manual = `/relevo ${other(verb)} ${self}`
371 const sent = await sendQuiet($, target, linkAsk(verb, self, fresh))
372 await update($, link, () => (sent ? 'waiting' : 'none'))
373
374 return sent
375 ? `esta sesión es ${verb.toUpperCase()} ⇄ ${target}. Enlace enviado; acéptalo en ${target} con: ${manual}`
376 : `esta sesión es ${verb.toUpperCase()} ⇄ ${target}. No se pudo enviar el enlace.\nEn la otra sesión: ${manual}`
377}
378
379// A command.run hook may not submit: the submit would wait on the turn the
380// hook holds. A timer's callback runs once the command has ended.
381function submitLater($: $, text: string) {
382 $.clock.after(0, () => void $.prompt.submit({ text }))
383}
384
385async function startGoal($: $, maxRounds: number, text: string): Promise<string> {
386 if ((await read($, role)) !== 'planner') {
387 return '/relevo goal solo funciona en la planificadora (/relevo planner <peer>).'
388 }
389 if (text === '') {
390 return 'Uso: /relevo goal <texto>'
391 }
392 await reset($)
393 await update($, goal, () => text)
394 await update($, turn, () => 'self')
395 submitLater($, `Objetivo de relevo:\n\n${text}`)
396
397 return `objetivo recibido, planificando (máximo ${maxRounds} rondas).`
398}
399
400async function pauseRelay($: $): Promise<string> {
401 if ((await read($, role)) === null) {
402 return 'no hay rol activo.'
403 }
404 await update($, paused, () => true)
405
406 return 'en pausa.'
407}
408
409async function resumeRelay($: $, maxRounds: number): Promise<string> {
410 if ((await read($, role)) === null) {
411 return 'no hay rol activo.'
412 }
413 await update($, paused, () => false)
414 await update($, isDone, () => false)
415 await update($, lastError, () => null)
416 // A resume past the limit grants another maxRounds from here.
417 const at = await read($, round)
418 if (at >= (await limitOf($, maxRounds))) {
419 await update($, cap, () => at + maxRounds)
420 }
421 const held = await read($, pending)
422 const outcome = held === null ? '' : ` Pendiente: ${await deliver($, maxRounds, held)}.`
423 // The peer's round that came in during the pause, now a turn for the model.
424 const waiting = await read($, inbox)
425 let handed = ''
426 if (waiting !== null && !(await read($, paused))) {
427 await update($, inbox, () => null)
428 await update($, turn, () => 'self')
429 submitLater($, waiting)
430 handed = ' Ronda recibida pasada al modelo.'
431 }
432
433 return `reanudado (límite ${await limitOf($, maxRounds)} rondas).${outcome}${handed}`
434}
435
436async function turnOff($: $): Promise<string> {
437 await update($, role, () => null)
438 await update($, peer, () => null)
439 await update($, paused, () => false)
440 await update($, isDone, () => false)
441 await update($, goal, () => null)
442 await update($, turn, () => null)
443 await update($, link, () => 'none')
444 await update($, lastError, () => null)
445 await update($, nonce, () => null)
446 await update($, invite, () => null)
447 await update($, inbox, () => null)
448 await update($, pending, () => null)
449 $.ui.status(undefined)
450
451 return 'rol quitado; no se envía nada más.'
452}
453
454// The pending link ask as one line, with the command that accepts it.
455async function inviteLine($: $): Promise<string | null> {
456 const asked = await read($, invite)
457
458 return asked === null
459 ? null
460 : `${asked.id} pide enlazar como ${asked.from}. Si es tu otra sesión, acepta con: /relevo ${other(asked.from)} ${asked.id}`
461}
462
463async function statusText($: $, maxRounds: number): Promise<string> {
464 const current = await read($, role)
465 const asked = await inviteLine($)
466 if (current === null) {
467 return [`sin rol. Esta sesión: ${await $.session.id()}`, ...(asked === null ? [] : [asked]), '', USAGE].join('\n')
468 }
469 const at = await read($, lastHandoffAt)
470 const sent = await read($, lastSent)
471 const state = (await read($, isDone)) ? 'terminado' : (await read($, paused)) ? 'en pausa' : 'activo'
472 const when = at === null ? 'nunca' : `hace ${ago((await $.clock.now()) - at)}`
473 const linked = { none: 'sin enlace', waiting: 'enlace enviado, sin respuesta', linked: 'enlazadas' }[
474 await read($, link)
475 ]
476
477 return [
478 `${current} ⇄ ${await read($, peer)} · ${state} · ${linked} · Esta sesión: ${await $.session.id()}`,
479 `ronda ${await read($, round)}/${await limitOf($, maxRounds)} · último envío ${when}`,
480 sent === null ? 'último mensaje: (ninguno)' : `último mensaje:\n${sent}`,
481 ...((await read($, inbox)) === null ? [] : ['ronda recibida en pausa: /relevo resume se la pasa al modelo.']),
482 ...(asked === null ? [] : [asked]),
483 ].join('\n')
484}
485
486// Whether a delivery was composed by another session's relevo: a peer's,
487// named relevo, and not an unverified mailbox entry. The name is the sender
488// engine's claim; a model's own SendMessage never carries it, so this keeps
489// model-written text out. It is not proof of who sent it: the nonce and the
490// person's confirmation are.
491function fromRelevo(origin: SessionReceiveOrigin): boolean {
492 if (origin.kind !== 'peer' || !('plugin' in origin) || origin.plugin !== 'relevo') {
493 return false
494 }
495
496 return !('isVerified' in origin) || origin.isVerified
497}
498
499const LONG_TOAST = { timeoutMs: 15000 }
500
501// The link messages relevo exchanges itself, never shown to the model. An ask
502// is held as an invite for the person to accept; nothing is ever answered to
503// an id a message names, so an asker learns nothing from this session. An ok
504// links only when it echoes the nonce this session sent, from the other role.
505async function answerLink($: $, text: string): Promise<void> {
506 const message = parseLink(text)
507 if (message === null) {
508 $.ui.toast('relevo: descartado un mensaje de enlace mal formado.')
509
510 return
511 }
512 const current = await read($, role)
513
514 if (message.kind === 'ask') {
515 if (message.id === (await $.session.id())) {
516 return
517 }
518 if (current !== null && (await read($, link)) === 'linked') {
519 $.ui.toast(`relevo: ${short(message.id)} pidió enlazar, pero esta sesión ya está enlazada; ignorado.`)
520
521 return
522 }
523 await update($, invite, () => ({ from: message.from, id: message.id, nonce: message.nonce }))
524 $.ui.toast(`relevo: ${(await inviteLine($)) ?? ''}`, LONG_TOAST)
525
526 return
527 }
528 const expected = await read($, nonce)
529 if (
530 current === null ||
531 message.from !== other(current) ||
532 expected === null ||
533 message.nonce !== expected ||
534 (await read($, link)) !== 'waiting'
535 ) {
536 return
537 }
538 await update($, link, () => 'linked')
539 // An ask that crossed this one is settled now: accepting it would re-key.
540 await update($, invite, () => null)
541 const held = (await read($, pending)) === null ? '' : ' /relevo resume envía lo retenido.'
542 $.ui.toast(`relevo: ${short(await read($, peer))} aceptó; sesiones enlazadas.${held}`)
543}
544
545export const register: Register = (on, options) => {
546 const maxRounds = Math.max(1, Number(options.maxRounds ?? 10))
547 let ticker: Timer | undefined
548 on('session.start', async ($, e, next) => {
549 await $.command.register({
550 name: 'relevo',
551 description: 'Conecta esta sesión con otra: una planifica, la otra construye',
552 argumentHint: 'planner|builder <peer> · goal <texto> · pause · resume · status · off',
553 })
554 await showStatus($)
555 // Keeps "hace 12s" moving while a relay is set up. One timer per load:
556 // a later session.start replaces it rather than adding another.
557 ticker?.cancel()
558 ticker = $.clock.every(1000, () => {
559 void read($, role).then(current => {
560 if (current !== null) $.ui.invalidate('ui.render')
561 })
562 })
563
564 return next(e)
565 })
566
567 on('command.run', { command: 'relevo' }, async ($, e) => {
568 const [verb = ''] = e.args.trim().split(/\s+/)
569 const arg = e.args.trim().slice(verb.length).trim()
570
571 if (verb === 'planner' || verb === 'builder') {
572 // The rest is the peer whole: a name two sessions share is "name [ref]".
573 if (arg === '') {
574 return { text: `Uso: /relevo ${verb} <peer>` }
575 }
576
577 return { text: await setRole($, verb, arg) }
578 }
579 if (verb === '') return { text: await openPane($) }
580 if (verb === 'goal') return { text: await startGoal($, maxRounds, arg) }
581 if (verb === 'pause') return { text: await pauseRelay($) }
582 if (verb === 'resume') return { text: await resumeRelay($, maxRounds) }
583 if (verb === 'off') return { text: await turnOff($) }
584 if (verb === 'status') return { text: await statusText($, maxRounds) }
585
586 return { text: USAGE }
587 })
588
589 on('prompt.compose', async ($, e, next) => {
590 const result = await next(e)
591 const current = await read($, role)
592 if (current === null) {
593 return result
594 }
595
596 return {
597 sections: [...result.sections, { id: 'relevo:role', text: PROMPTS[current], scope: 'session' }],
598 }
599 })
600
601 on('session.receive', { origin: { kind: /^peer/ } }, async ($, e, next) => {
602 if (looksLikeLink(e.text)) {
603 if (!fromRelevo(e.origin)) {
604 $.ui.toast('relevo: descartado un mensaje de enlace que no viene de relevo.')
605
606 return { consumed: 'relevo: enlace que no viene de relevo' }
607 }
608 await answerLink($, e.text)
609
610 return { consumed: 'relevo: mensaje de enlace' }
611 }
612 const current = await read($, role)
613 // Without a role this session follows no relevo prompt: a header is text.
614 if (current === null || !looksLikeHeader(e.text)) {
615 return next(e)
616 }
617 // A round counts only when the linked peer's relevo sent it: relevo-made,
618 // the link's nonce, and the other role. Anything else shaped as one is
619 // dropped, as the role prompt tells the model to act on such a header.
620 const relayed = parseHeader(e.text)
621 const expected = await read($, nonce)
622 if (
623 relayed === null ||
624 !fromRelevo(e.origin) ||
625 expected === null ||
626 relayed.nonce !== expected ||
627 relayed.from !== other(current)
628 ) {
629 $.ui.toast('relevo: descartado un mensaje de ronda no verificado.')
630
631 return { consumed: 'relevo: ronda no verificada' }
632 }
633
634 // Never lowered: a stale or forged low round cannot reset the count.
635 await update($, round, at => Math.max(at, relayed.round))
636 await update($, lastReceived, () => relayed.body)
637 // The nonce proves the peer accepted, even if its ack was lost.
638 await update($, link, () => 'linked')
639 await update($, invite, () => null)
640 const now = await $.clock.now()
641 await update($, lastReceivedAt, () => now)
642 const plain = `${header(relayed.round, relayed.from)}\n\n${relayed.body}`
643
644 // Paused or done: no model turn until the person resumes.
645 if ((await read($, paused)) || (await read($, isDone))) {
646 await update($, inbox, () => plain)
647 $.ui.toast(
648 `relevo: llegó la ronda ${relayed.round} de ${short(await read($, peer))} en pausa; /relevo resume se la pasa al modelo.`,
649 )
650
651 return { consumed: 'relevo: ronda retenida en pausa' }
652 }
653 await update($, turn, () => 'self')
654
655 // Only ever passed on: the model reads it as a new turn, under the plain
656 // header. Nothing is sent from here, so a peer's message never goes back
657 // out without a turn.
658 return next({ ...e, text: plain })
659 }).catch(($, e, next) =>
660 // Fails closed: a relevo message whose checks threw never reaches the model.
661 looksLikeLink(e.text) || looksLikeHeader(e.text) ? { consumed: 'relevo: error al leer un mensaje' } : next(e),
662 )
663
664 on('turn.complete', async ($, e, next) => {
665 const result = await next(e)
666 if (e.agentId !== undefined || e.reason !== 'answer') {
667 return result
668 }
669 const current = await read($, role)
670 const to = await read($, peer)
671 if (current === null || to === null || (await read($, isDone))) {
672 return result
673 }
674
675 const text = await answerOf($, e.answer)
676
677 if (current === 'planner') {
678 const done = extractTag(text, 'done')
679 if (done !== null) {
680 await update($, isDone, () => true)
681 await update($, paused, () => true)
682 await update($, turn, () => null)
683 $.ui.toast(`relevo: terminado. ${done.split('\n')[0]}`)
684
685 return result
686 }
687 }
688
689 const tagged = extractTag(text, 'handoff')
690 if (tagged === null) {
691 return result
692 }
693 // Compared without a header of the model's own, as deliver sends it.
694 const body = stripHeader(tagged)
695 if (sameText(body, await read($, lastReceived)) || sameText(body, await read($, lastSent))) {
696 return result
697 }
698 if (await read($, paused)) {
699 await update($, pending, () => body)
700
701 return result
702 }
703 await deliver($, maxRounds, body)
704
705 return result
706 })
707
708 // Drawn only while this session has a role: with relevo always loaded, a
709 // session that does not use it keeps its prompt as it was.
710 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
711 if (e.props.hasSurvey) {
712 return next(e)
713 }
714 const live = await liveStatus($, maxRounds)
715 if (live === null) {
716 return next(e)
717 }
718 const { Box, Text, Button } = $.ui.resolve(e)
719
720 return (
721 <Box width={e.props.bodyColumns} flexDirection="row" gap={1}>
722 <Box flexGrow={1}>{liveLine(Text, live, e.props.bodyColumns)}</Box>
723 <Button key="open" label="⚙" plain onPress={() => void openPane($)} />
724 </Box>
725 )
726 })
727
728 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
729 const ui = $.ui.resolve(e)
730 const { Box, Text, Button } = ui
731 const columns = e.props.bodyColumns
732 const live = await liveStatus($, maxRounds)
733 const self = await $.session.id()
734 // $.ui.toast carries no plugin name, unlike a command's output.
735 // A toast is one line: the command's second line follows after a dot.
736 const toast = (text: string) => $.ui.toast(`relevo: ${text.replace(/\n/g, ' · ')}`)
737 const connect = async () => {
738 const to = ((await read($, draftPeer)) || (await read($, peer)) || '').trim()
739 toast(to === '' ? 'escribe el peer antes de conectar.' : await setRole($, await read($, draftRole), to))
740 }
741 const begin = async (text: string) => toast(await startGoal($, maxRounds, text.trim()))
742
743 const controls = (
744 <Box flexDirection="row" gap={1}>
745 {live?.isPaused === true ? (
746 <Button
747 key="resume"
748 label="Reanudar"
749 variant="primary"
750 onPress={() => void resumeRelay($, maxRounds).then(toast)}
751 />
752 ) : null}
753 {live !== null && !live.isPaused ? (
754 <Button key="pause" label="Pausa" onPress={() => void pauseRelay($).then(toast)} />
755 ) : null}
756 {live !== null ? <Button key="off" label="Quitar" onPress={() => void turnOff($).then(toast)} /> : null}
757 <Button key="close" label="Cerrar" role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
758 </Box>
759 )
760
761 const card = (
762 <Box
763 flexDirection="column"
764 borderStyle="round"
765 borderColor={live === null ? 'claude' : ROLE_COLOR[live.current]}
766 paddingX={1}
767 >
768 {liveLine(Text, live, columns - 4)}
769 {live === null ? (
770 <Text dimColor wrap="truncate-end">
771 una sesión planifica, la otra construye, y se pasan el trabajo solas
772 </Text>
773 ) : null}
774 {await (async () => {
775 const objective = await read($, goal)
776
777 return objective === null ? null : (
778 <Text wrap="truncate-end">
779 <Text dimColor>objetivo </Text>
780 {objective}
781 </Text>
782 )
783 })()}
784 {live?.link === 'waiting' ? (
785 <Text color="warning" wrap="wrap">
786 esperando que {short(live.to)} acepte. Allí: Aceptar en su panel, o /relevo {other(live.current)} {self}
787 </Text>
788 ) : null}
789 {await (async () => {
790 const failed = await read($, lastError)
791
792 return failed === null ? null : (
793 <Text color="error" wrap="truncate-end">
794 ✗ {failed}
795 </Text>
796 )
797 })()}
798 {await (async () => {
799 const asked = await read($, invite)
800
801 return asked === null ? null : (
802 <Box flexDirection="row" gap={1}>
803 <Box flexGrow={1}>
804 <Text color="warning" wrap="wrap">
805 ⚑ {asked.id} pide enlazar como {asked.from}; esta sesión sería {other(asked.from)}. Acepta solo si
806 es tu otra sesión.
807 </Text>
808 </Box>
809 <Button
810 key="accept"
811 label="Aceptar"
812 variant="primary"
813 onPress={() => void setRole($, other(asked.from), asked.id).then(toast)}
814 />
815 </Box>
816 )
817 })()}
818 </Box>
819 )
820
821 // Mobile draws no Select or Input: the status and the buttons alone.
822 if (!('Select' in ui) || !('Input' in ui)) {
823 return (
824 <Box flexDirection="column" width={columns} gap={1}>
825 {card}
826 {controls}
827 </Box>
828 )
829 }
830 const { Select, Input } = ui
831
832 // Before a role: only what connecting needs.
833 if (live === null) {
834 const typed = await read($, draftPeer)
835 const found = await read($, peers)
836 // A name two sessions share is offered, and sent to, as "name [ref]".
837 const pickOf = (one: RelevoPeer) =>
838 one.ref !== undefined && found.filter(two => two.name === one.name).length > 1
839 ? `${one.name} [${one.ref}]`
840 : one.name
841 const picked = found.some(one => pickOf(one) === typed) ? typed ?? undefined : undefined
842 const chosen = await read($, draftRole)
843
844 return (
845 <Box flexDirection="column" width={columns} gap={1}>
846 {card}
847 <Box flexDirection="row" gap={1}>
848 <Text color={ROLE_COLOR[chosen]} bold>
849 1
850 </Text>
851 <Select
852 key="role"
853 label="Rol"
854 value={chosen}
855 options={[
856 { value: 'planner', label: 'planner · divide el objetivo en pasos' },
857 { value: 'builder', label: 'builder · ejecuta cada paso' },
858 ]}
859 onSelect={value => void update($, draftRole, () => (value === 'builder' ? 'builder' : 'planner'))}
860 />
861 </Box>
862 <Box flexDirection="row" gap={1}>
863 <Text color={ROLE_COLOR[other(chosen)]} bold>
864 2
865 </Text>
866 <Box flexDirection="column" flexGrow={1}>
867 <Box flexDirection="row" gap={1}>
868 {found.length > 0 ? (
869 <Select
870 key="peerPick"
871 label="Sesiones"
872 value={picked}
873 options={found.map(one => ({
874 value: pickOf(one),
875 label: one.status === undefined ? pickOf(one) : `${pickOf(one)} · ${one.status}`,
876 }))}
877 onSelect={value => void update($, draftPeer, () => value)}
878 />
879 ) : (
880 <Text dimColor>no se encontraron otras sesiones; escribe el nombre o id</Text>
881 )}
882 <Button key="refresh" label="↻" plain onPress={() => void loadPeers($)} />
883 </Box>
884 <Input
885 key="peer"
886 label="Peer"
887 placeholder="nombre o id de la otra sesión"
888 value={typed ?? ''}
889 submitLabel="conectar"
890 onInput={value => void update($, draftPeer, () => value)}
891 onSubmit={value => void update($, draftPeer, () => value).then(connect)}
892 />
893 <Text dimColor wrap="truncate-end">
894 esta sesión: {self}
895 </Text>
896 </Box>
897 </Box>
898 <Box flexDirection="row" gap={1}>
899 <Text color="success" bold>
900 3
901 </Text>
902 <Button key="connect" label="Conectar" variant="primary" onPress={() => void connect()} />
903 <Text dimColor wrap="truncate-end">
904 la otra sesión confirma el enlace
905 </Text>
906 </Box>
907 {controls}
908 </Box>
909 )
910 }
911
912 // With a role: the relay itself, the last handoff each way.
913 const sent = await read($, lastSent)
914 const got = await read($, lastReceived)
915 const held = await read($, pending)
916 const sentAt = await read($, lastHandoffAt)
917 const gotAt = await read($, lastReceivedAt)
918 const now = await $.clock.now()
919 const isWide = columns >= 100
920 const half = isWide ? Math.floor((columns - 1) / 2) : columns
921 const lines = isWide ? 6 : 4
922 const box = (key: string, title: string, color: Color, body: string | null, at: number | null) => (
923 <Box
924 key={key}
925 flexDirection="column"
926 borderStyle="round"
927 borderColor={color}
928 paddingX={1}
929 width={half}
930 >
931 <Text wrap="truncate-end">
932 <Text color={color} bold>
933 {title}
934 </Text>
935 {at === null ? null : <Text dimColor> · hace {ago(now - at)}</Text>}
936 </Text>
937 {body === null ? (
938 <Text dimColor>(nada aún)</Text>
939 ) : (
940 preview(body, lines).map((row, index) => (
941 <Text key={`${key}-${index}`} wrap="truncate-end" dimColor={row.startsWith('… ')}>
942 {row}
943 </Text>
944 ))
945 )}
946 </Box>
947 )
948 const objective = await read($, goal)
949 const done = await read($, isDone)
950
951 return (
952 <Box flexDirection="column" width={columns} gap={1}>
953 {card}
954 {live.current === 'planner' && (objective === null || done) ? (
955 <Box flexDirection="row" gap={1}>
956 <Input
957 key="goal"
958 label="Objetivo"
959 placeholder="qué hay que construir"
960 value={await read($, draftGoal)}
961 submitLabel="iniciar"
962 onInput={value => void update($, draftGoal, () => value)}
963 onSubmit={value => void begin(value)}
964 />
965 <Button key="start" label="Iniciar" variant="primary" onPress={() => void read($, draftGoal).then(begin)} />
966 </Box>
967 ) : null}
968 <Box flexDirection={isWide ? 'row' : 'column'} gap={isWide ? 1 : 0}>
969 {box('sent', `↑ enviado · ronda ${live.n}`, ROLE_COLOR[live.current], sent, sentAt)}
970 {box('got', `↓ recibido de ${short(live.to)}`, ROLE_COLOR[other(live.current)], got, gotAt)}
971 </Box>
972 {held === null ? null : (
973 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
974 <Text color="warning" bold>
975 ⏸ retenido · sale con Reanudar
976 </Text>
977 {preview(held, 3).map((row, index) => (
978 <Text key={`held-${index}`} wrap="truncate-end">
979 {row}
980 </Text>
981 ))}
982 </Box>
983 )}
984 {controls}
985 </Box>
986 )
987 })
988}
989
990// The turn's final text carries the tags; when it does not (a tool call came
991// last), the newest assistant message from the transcript is read instead.
992async function answerOf($: $, answer: string): Promise<string> {
993 if (/<(handoff|done)>/.test(answer)) {
994 return answer
995 }
996 try {
997 const rows = await $.session.messages()
998 const last = Array.isArray(rows) ? rows.filter(row => row.role === 'assistant').at(-1) : undefined
999
1000 return last?.text ?? answer
1001 } catch {
1002 return answer
1003 }
1004}
1005hooks/relay.ts 141 lines1import type { RelevoRole } from '../types'
2
3// Anchored: a delivery's text is its body as the model reads it, with no
4// envelope before it, so a header is one only at the very start. On the wire
5// it carries the link's nonce; the model reads it, and may copy it, without.
6const HEADER = /^\s*\[relevo ronda (\d+) · de (planner|builder)(?: · ([\w-]+))?\]\s*/
7
8// The last whole block: the last closing tag, and the last opening before it.
9// A tag quoted in code (`<handoff>` in prose, a fenced example) is not one:
10// code is blanked out, same length, before the tags are looked for.
11export function extractTag(text: string, tag: 'handoff' | 'done'): string | null {
12 const blank = (code: string) => ' '.repeat(code.length)
13 const masked = text.replace(/```[\s\S]*?```/g, blank).replace(/`[^`\n]*`/g, blank)
14 const open = `<${tag}>`
15 const close = masked.lastIndexOf(`</${tag}>`)
16 const start = close === -1 ? -1 : masked.lastIndexOf(open, close)
17 if (start === -1) {
18 return null
19 }
20 const body = text.slice(start + open.length, close).trim()
21
22 return body === '' ? null : body
23}
24
25export function header(round: number, from: RelevoRole, nonce?: string): string {
26 return nonce === undefined ? `[relevo ronda ${round} · de ${from}]` : `[relevo ronda ${round} · de ${from} · ${nonce}]`
27}
28
29// Text that starts as a round header does, well formed or not.
30export function looksLikeHeader(text: string): boolean {
31 return /^\s*\[relevo ronda/.test(text)
32}
33
34// The body without a header the model already wrote at its start, nor the
35// blank lines after it: deliver puts its own, so the peer reads one.
36export function stripHeader(body: string): string {
37 const match = HEADER.exec(body)
38
39 return match === null ? body : body.slice(match[0].length).trim()
40}
41
42export function parseHeader(
43 text: string,
44): { round: number; from: RelevoRole; nonce: string | null; body: string } | null {
45 const match = HEADER.exec(text)
46 if (match === null) {
47 return null
48 }
49
50 return {
51 round: Number(match[1]),
52 from: match[2] as RelevoRole,
53 nonce: match[3] ?? null,
54 body: text.slice(match[0].length).trim(),
55 }
56}
57
58// Collapses whitespace so a body the model copied back with other line breaks
59// still counts as the same text.
60export function sameText(a: string | null, b: string | null): boolean {
61 if (a === null || b === null) {
62 return false
63 }
64
65 return a.replace(/\s+/g, ' ').trim() === b.replace(/\s+/g, ' ').trim()
66}
67
68// A session id is a uuid or a remote id; anything else is a name as
69// ListAgents prints it.
70export function address(peer: string): string | { sessionId: string } {
71 const isId =
72 /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(peer) ||
73 peer.startsWith('session_')
74
75 return isId ? { sessionId: peer } : peer
76}
77
78export function ago(ms: number): string {
79 const seconds = Math.max(0, Math.round(ms / 1000))
80 if (seconds < 60) {
81 return `${seconds}s`
82 }
83 const minutes = Math.round(seconds / 60)
84
85 return minutes < 60 ? `${minutes}m` : `${Math.round(minutes / 60)}h`
86}
87
88// The link. A asks B to take the other role, naming its own session id and a
89// nonce of its making; B's person accepts, and B acks echoing the nonce.
90// None matches HEADER, so parseHeader never reads one.
91export type LinkMessage =
92 | { kind: 'ask'; from: RelevoRole; id: string; nonce: string }
93 | { kind: 'ok'; from: RelevoRole; nonce: string }
94
95// Anchored at both ends: a message is a link message only when its whole
96// text is one link line, so a handoff that quotes one stays a handoff.
97const LINK_ASK = /^\s*\[relevo enlace · (planner|builder) (\S+) ([\w-]+)\]\s*$/
98const LINK_OK = /^\s*\[relevo enlace-ok · (planner|builder) ([\w-]+)\]\s*$/
99
100export function linkAsk(from: RelevoRole, id: string, nonce: string): string {
101 return `[relevo enlace · ${from} ${id} ${nonce}]`
102}
103
104export function linkOk(from: RelevoRole, nonce: string): string {
105 return `[relevo enlace-ok · ${from} ${nonce}]`
106}
107
108export function parseLink(text: string): LinkMessage | null {
109 const ask = LINK_ASK.exec(text)
110 if (ask !== null) {
111 return { kind: 'ask', from: ask[1] as RelevoRole, id: ask[2] ?? '', nonce: ask[3] ?? '' }
112 }
113 const ok = LINK_OK.exec(text)
114
115 return ok === null ? null : { kind: 'ok', from: ok[1] as RelevoRole, nonce: ok[2] ?? '' }
116}
117
118// Text that starts as a link message does, well formed or not: what a
119// session must not take from anyone but another session's relevo.
120export function looksLikeLink(text: string): boolean {
121 return /^\s*\[relevo enlace/.test(text)
122}
123
124// Rounds as a row of cells, `width` wide: filled up to n of limit.
125export function bar(n: number, limit: number, width: number): { done: string; left: string } {
126 const filled = limit <= 0 ? 0 : Math.min(width, Math.round((Math.min(n, limit) / limit) * width))
127
128 return { done: '▰'.repeat(filled), left: '▱'.repeat(width - filled) }
129}
130
131// The first lines of a handoff, each cut short, for a preview.
132export function preview(text: string, lines: number): string[] {
133 const rows = text.split('\n').map(row => row.trimEnd()).filter(row => row.trim() !== '')
134 const shown = rows.slice(0, lines)
135 if (rows.length > lines) {
136 shown.push(`… ${rows.length - lines} líneas más`)
137 }
138
139 return shown
140}
141types/index.d.ts 47 lines1export type RelevoRole = 'planner' | 'builder'
2
3// Another session ListAgents lists, as the pane's peer picker offers it.
4export type RelevoPeer = { name: string; ref?: string; status?: string }
5
6// Who holds the work now: this session's model, the peer's, or nobody yet.
7export type RelevoTurn = 'self' | 'peer' | null
8
9// The one-step link: none asked, sent and waiting for the ack, or acked.
10export type RelevoLink = 'none' | 'waiting' | 'linked'
11
12// A link another session's relevo asked for, held until the person accepts:
13// the role the asker takes, its session id and the nonce it chose.
14export type RelevoInvite = { from: RelevoRole; id: string; nonce: string }
15
16declare module 'claude-code' {
17 interface PluginState {
18 relevo: {
19 role: RelevoRole | null
20 peer: string | null
21 round: number
22 paused: boolean
23 isDone: boolean
24 lastHandoffAt: number | null
25 lastReceivedAt: number | null
26 lastReceived: string | null
27 lastSent: string | null
28 pending: string | null
29 cap: number | null
30 goal: string | null
31 turn: RelevoTurn
32 link: RelevoLink
33 lastError: string | null
34 // The link's shared secret: the asker makes it, the ack echoes it.
35 nonce: string | null
36 invite: RelevoInvite | null
37 // A round the peer sent while paused or done, as the model will read it;
38 // /relevo resume hands it to the model.
39 inbox: string | null
40 draftRole: RelevoRole
41 draftPeer: string | null
42 draftGoal: string
43 peers: RelevoPeer[]
44 }
45 }
46}
47