SLOPSHOPPER

relevo

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

newpanebandcommandtoaststatus
v0.2.0MITupdated 2026-10-12melandres8/claude-mods/relevo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · relevo
│ ┃ relevo ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ relevo · sin rol ⏺ Read(src/auth.ts) │ ┃ │ una sesión planifica, la otra construye, y ⎿ Read 6 lines │ ┃ ╰─────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 1 Rol: planner · divide el objetivo en pasos ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ 2 no se encontraron otras sesiones; escribe │ ┃ Peer: nombre o id de la otra sesión ⏎ cone ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ esta sesión: preview-session │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ 3 [ Conectar ] la otra sesión confirma el en │ ┃ › /relevo │ ┃ [ Cerrar ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · relevo
╭──────────────────────────────────────────────────────╮ │ relevo · sin rol │ │ una sesión planifica, la otra construye, y se pasan… │ ╰──────────────────────────────────────────────────────╯ 1 Rol: planner · divide el objetivo en pasos ▾ 2 no se encontraron otras sesiones; escribe el nombre o id ↻ Peer: nombre o id de la otra sesión ⏎ conectar esta sesión: preview-session 3 [ Conectar ] la otra sesión confirma el enlace [ Cerrar ]
README

relevo

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ú.

Activarlo

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.

Uso

Abre dos sesiones de Claude Code en el proyecto. Llamamos A a la que planifica y B a la que construye.

Panel

  1. En A, escribe /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.
  2. Conectar envía a B una petición de enlace. B no toma ningún rol por su cuenta: muestra un aviso con el id de A y el comando que la acepta, por ejemplo /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.
  3. En A aparece el campo Objetivo. Escríbelo y pulsa Iniciar.

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.

Con comandos

Lo mismo sin panel:

  1. En A: `` /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>`.
  2. En B, pega esa línea. Es la confirmación: acepta la petición de A y enlaza las dos sesiones. `` /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.
  3. En A: `` /relevo goal Añadir paginación al endpoint /users con tests ``

Comandos

ComandoQué hace
/relevoAbre 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 / resumeCorta o reanuda el envío automático. resume envía el handoff retenido y pasa al modelo la ronda que llegó en pausa.
/relevo statusMuestra 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 offQuita el rol. Una sesión enlazada rechaza `/relevo planner\builder hasta que ejecutas /relevo off`.

Protecciones

  • 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>.
  • Sin enlace no se envía nada: hasta que el peer acepta, o si los roles chocan (dos builders, por ejemplo), el <handoff> queda retenido y relevo se pausa. Cuando el peer acepta, /relevo resume lo envía.
  • Envío fallido o rechazado: toast con el motivo y pausa. El <handoff> queda retenido, y /relevo resume lo reenvía con la misma ronda.
  • No a sí misma: el panel no ofrece la propia sesión en la lista, y tanto el panel como /relevo planner|builder rechazan como peer el id de esta sesión.
  • Enlace con confirmación: una petición de enlace nunca da un rol por sí sola. Queda pendiente hasta que la persona la acepta con /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.
  • Solo mensajes de relevo: un mensaje de enlace o de ronda cuenta solo si lo compuso el relevo de la otra sesión (origen 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.
  • Nonce por enlace: quien pide el enlace genera un nonce aleatorio. La aceptación lo devuelve, y cada cabecera de ronda lo lleva. Una ronda cuenta solo si trae el nonce del enlace y viene del rol opuesto. La ronda recibida nunca baja la cuenta, así que una cabecera ronda 0 no la reinicia ni se salta maxRounds.
  • En pausa o terminado: una ronda que llega entonces no abre un turno del modelo. Queda retenida (toast y línea en /relevo status), y /relevo resume se la pasa al modelo.
  • Sin eco: relevo solo envía desde el final de un turno del modelo, nunca al recibir un mensaje. Tampoco reenvía un <handoff> igual al que acaba de recibir o al último que envió.

Notas

  • Si el modelo ya escribe la cabecera al principio del <handoff>, relevo la quita antes de poner la suya, así que el peer la lee una sola vez.
  • Si dos sesiones comparten nombre, el panel las ofrece como nombre [ref], la forma que acepta SendMessage. /relevo planner|builder nombre [ref] también la acepta.
  • Solo la planificadora sube la ronda. La constructora contesta con la misma ronda que recibió. En el envío, la cabecera es [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.
  • Una cabecera o una línea de enlace cuentan solo al principio del mensaje (una línea de enlace, además, solo si es el mensaje entero). Un <handoff> que cita una de las dos sigue siendo un handoff.
  • El <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().
  • El rol, el peer, la ronda y la pausa viven en $.state, así que aguantan una recarga en caliente.
  • Un mensaje del peer entra por 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.

Desarrollo

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 1005 lines
1import { 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}
1005
hooks/relay.ts 141 lines
1import 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}
141
types/index.d.ts 47 lines
1export 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