Your Claude Code sessions on a braille radar, in a pane opened by /radar: each session that runs radar beats a beacon (repository, branch, tool running…

One pane, opened by /radar, that shows every Claude Code session running radar on this machine: a braille radar whose sweep lights each session up as it passes, and beside it the list of who does what.
⣀⡤⠔⠒⠋⠉⢙⠉⠉⠙⠒⠢⢤⣀ ⌖ Control tower 3 sessions
⢀⡴⠊⠁ ⢐ ⠈⠑⢦⡀ ● ccmods (you) main
⢀⡴⠁ ⢀⡠⠤⠖⢒⠒⠲⠤⢄⡀ ⠈⢦⡀ ⠼ Bash · 12 s
⢠⠎ ⡠⠚⠁ ⢐ ⠈⠓⢄ ⠱⡄ ● docs main
⢀⠏ ⢀⠞ ⢀⣀⣴⢤⣀⡀ ⠶⠳⡀ ⠹⡀ ◌ awaits an answer · 6 min
⣼⣿⢷⣶⢤⣎⣀ ⢀⡔⠉ ⢐ ⠉⠶⡀ ⢱ ⠶⢧ ● site main
⣿⢽⢿⢽⢿⢽⢿⢽⢷⣾⢤⢄⢀⢐⢀⢀⢀⢀⢧⢀⢀⢀⢈⣇⢀⢀⢀⢸ · idle · 54 min
Where a blip sits says what its session does:
AskUserQuestion) mid-way, idle at the rim, drifting outwards over an hour;A session that takes tasks from a conductor carries ⛑ beside its name. It is the session's own say, in that session: nothing else offers it work, and it stops taking any once it ends or after a /clear.
While it takes tasks, the band above its prompt says so, with Stop: an idle terminal that can be handed work does not look like any other. Once a task arrives, the band names it and the session that handed it over (⛑ T-12 Token expiry · handed by ccmods), until the session sends its DELIVERED or BLOCKED report.
| Command | Effect |
|---|---|
/radar | opens the pane |
/radar available | this session takes tasks from a conductor, or stops; the pane's ⛑ Take tasks button does the same |
/radar conductor | opens the pane and watches the conductor's tasks file from then on |
A session shows up once radar runs in it: install the plugin at the user scope and every new session carries it.
The animated radar is drawn in the terminal. Claude desktop and the other surfaces have no Raster, and draw the list alone.
One session hands out the work, the others deliver. From the conductor's frame, under the radar's in the same pane, this session hands the tasks of a checklist to the sessions on the radar that take tasks, one each, and follows what comes back; it pays in context for their one-line reports alone.
⛑ Conductor docs/TASKS.md · 4/9 ✓ · 3 sessions · manual
╭──────────────────────────────────────────────────────────────╮
│ Tasks file [docs/TASKS.md ] │
│ ● T-12 Token expiry → api ⠼ working · 12 min ⎇ conductor/t-12 │
│ ● T-14 Quota screen → web ✓ delivered · 3 min ⎇ conductor/t-14│
│ ╭──────────────────────────────────────────────────────────╮ │
│ │ ● T-15 Load tests → cloud ? question · 1 min │ │
│ │ ✉ 200 or 2000 req/s? │ │
│ │ ↩ Answer cloud [ ] │ │
│ ╰──────────────────────────────────────────────────────────╯ │
│ queue: T-16 T-17 T-18 │
│ waits on T-12: T-19 │
│ yours (👤): T-7 │
│ [⇢ T-16 → docs] [▶ Auto] [↻ Reload] │
╰──────────────────────────────────────────────────────────────╯
A session takes no task until you say so in it, with /radar available or ⛑ Take tasks: an idle terminal you were about to type in is never handed work. It is offered a task once it is idle and holds none.
In the pane:
tasksFile option's file, then TASKS.md, then docs/TASKS.md.One checklist line per task; the lines indented under it are its brief, sent with it; (after …) names its prerequisites; a 👤 in the title makes it yours: shown under yours, never handed to a session.
- [ ] T-12 Token expiry
Tokens must expire after 15 min; see src/auth.ts.
- [x] T-14 Quota screen
- [ ] T-19 Load tests (after T-12, T-14)
- [ ] T-7 👤 Buy the domain
- [ ] A task with no id
An id is the first word when it reads T-12, API-3 and the like; a line without one is #5, its rank among the tasks. A task is ready when its box is empty, it is not marked 👤, it was never given out, and every prerequisite the file holds is checked. A delivery checks its box.
A task goes out with $.session.send({ to: { sessionId } }): its id, title and brief, the folder to work in, and the reply expected, one message whose first line is DELIVERED T-12: …, BLOCKED T-12: … or QUESTION T-12: ….
The conductor's session.receive hook takes a report on a task it gave out before the model sees it: the pane, the band and a toast show it, and the task's state moves. Any other message goes through untouched. DELIVERED checks the task in the file and frees the session; QUESTION holds it until you answer; BLOCKED frees it and leaves the task out of the queue until you requeue it.
Who holds which task is kept per repository, across sessions ($.store, the last 200): a conductor that restarts hands nothing out twice.
Each task handed out gets a git worktree and a branch of its own, conductor/t-12, in a folder beside the repository, <parent>/<repository>.tasks/t-12: sessions never share a working copy, and their commits never mix. The brief says where to work and on which branch, and asks for the commit in the report; the pane shows the branch beside the task (⎇ conductor/t-12). Merging is yours.
A task that waits on others starts from what they delivered: from the branch of its first prerequisite, the brief asking to git merge the others' before starting. A prerequisite not delivered yet holds the task back anyway; one delivered and already merged changes nothing.
A requeued task finds its worktree and its branch where it left them. The worktrees stay once the work is merged: git worktree remove <folder>, or git worktree prune once the folder is gone.
With isolation set to shared, every session works in the conductor's folder, on its branch, told to touch only the files its task needs.
| Option | Values | Default |
|---|---|---|
language | en, fr | en |
isolation | worktree, shared | worktree |
tasksFile | the conductor's checklist, from the repository's root or absolute, looked for first | TASKS.md |
extraConfigDirs | configuration folders, separated by ; or , | none |
Set from /config, or under pluginConfigs.radar in settings.
A session sees the sessions of its own configuration folder. Sessions started under another one (CLAUDE_CONFIG_DIR set to ~/.claude-2 in one terminal, unset in another) are seen once extraConfigDirs names that folder: ~/.claude; ~/.claude-2, ~ standing for the home folder. Not :, which a Windows path holds.
One side is enough. A session writes its beacon under its own folder and under each one the option names, so the sessions of the other configuration see it too, without setting anything there; a session found in two folders is drawn once. The writes stay in radar/beacons/ of each folder named: nothing else of that configuration is touched.
The beacon. Each session writes one JSON file, <config dir>/radar/beacons/<session id>.json: its repository, branch, state, the tool running, when that state began and when it last beat. The config dir is CLAUDE_CONFIG_DIR when set, else ~/.claude, so sessions share a radar when they share a configuration; the same file goes under each folder extraConfigDirs names. The file is rewritten on a change (at most once a second) and every 5 s otherwise. After a /clear the session beats under its new id.
Reading the others. Every 2 s the session lists its folder and those extraConfigDirs names, reads again only the files whose modification time moved, and keeps the beacons that are live: not ended, and beaten within the last 30 s.
Ending. A plugin's file system has no delete: a session that ends writes its beacon one last time with endedAt, and a session that crashed drops off once its beat is 30 s old. The files stay, one small file per session ever run; the folder can be emptied at any time.
The screen. The radar is one Raster of braille cells, 28 × 14 beside the list from 64 columns, up to 24 × 12 above it below that: 2 × 4 dots a cell, square dots on a cell twice as tall as wide, so the screen is round. The rings and the cross are dim dots; the sweep turns in 3.2 s, its trail fading from bright to dark green through an ordered dither. A blip is ⠶, ⣿ just after the beam crosses it, then fades. A timer repaints the cells in place with $.ui.blit twenty times a second, with no redraw of the pane; the list redraws once a second. The timer stops when the pane closes.
isolation: 'remote': a cloud session cannot message another session back yet.hooks/register.tsx 645 lines1/**
2 * radar: every Claude Code session that runs it on this machine, on a
3 * braille radar in a pane opened by /radar; and the conductor, opened by
4 * /radar conductor, which hands the tasks of a checklist to the sessions
5 * that take them.
6 *
7 * Each session beats a beacon (beacon.ts) and reads the others'; the pane
8 * draws them on a Raster (scope.ts) that a timer repaints in place, with the
9 * list beside it. A task goes out as a brief (protocol.ts); the session
10 * answers with one line, DELIVERED, BLOCKED or QUESTION, which
11 * `session.receive` takes before the model sees it (conductor.tsx draws it).
12 *
13 * @module register
14 */
15
16import { atom, read, update } from 'claude-code'
17import type { Register, RenderElement, Timer } from 'claude-code'
18
19import type { Activity, Assignment, Received, Task } from '../types'
20import { BEAT_MS, beaconDir, beaconDirs, decode, encode, fileOf, peersOf, STALE_MS } from './beacon'
21import type { Beacon } from './beacon'
22import { CONDUCTOR_COLOR, DRAFT_PATH, drawConductor, drawQuestion, latest, lit, sessionsOf } from './conductor'
23import type { Move } from './conductor'
24import { answer, brief, draft, reportOf } from './protocol'
25import type { Place } from './protocol'
26import { sayOf } from './say'
27import type { Texts } from './say'
28import { frame } from './scope'
29import { blips, briefOf, closes, COLOR, elapsed, stateOf } from './sightings'
30import { assignmentOf, assignmentsOf, baseOf, candidatesOf, KEPT, parseTasks, plan, resolveIn, tickTask, worktreeOf } from './tasks'
31
32const PANE = 'radar'
33/** The radar's frame and title. */
34const RADAR_COLOR = '#56d364'
35const WATCH_MS = 2000
36const DRAFT_WAIT_MS = 10 * 60_000
37const SCOPE = 'scope'
38const FRAME_MS = 50
39const LIST_MS = 1000
40const TICK_MS = 1000
41const SCAN_MS = 2000
42const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
43
44const activity = atom({ plugin: 'radar', key: 'activity' } as const, {
45 tool: null,
46 isTurn: false,
47 wait: null,
48 since: 0,
49} as Activity)
50const peersAtom = atom({ plugin: 'radar', key: 'peers' } as const, [] as Beacon[])
51const availableAtom = atom({ plugin: 'radar', key: 'isAvailable' } as const, false)
52const taskAtom = atom({ plugin: 'radar', key: 'task' } as const, null as Received | null)
53const tasksAtom = atom({ plugin: 'radar', key: 'tasks' } as const, [] as Task[])
54const assignmentsAtom = atom({ plugin: 'radar', key: 'assignments' } as const, [] as Assignment[])
55const fileAtom = atom({ plugin: 'radar', key: 'file' } as const, '')
56const chosenAtom = atom({ plugin: 'radar', key: 'chosen' } as const, '')
57const autoAtom = atom({ plugin: 'radar', key: 'isAuto' } as const, false)
58
59/** The store keys of one repository's tasks file and of what the conductor gave out there. */
60const fileKey = (root: string) => `tasksFile:${root}`
61const assignmentsKey = (root: string) => `assignments:${root}`
62
63// Module variables: a reload starts them over, and `session.start` fires again.
64let current: Activity = { tool: null, isTurn: false, wait: null, since: Date.now() }
65let peers: Beacon[] = []
66let isAvailable = false
67let selfId = ''
68let selfName = 'session'
69let selfBranch = ''
70/** Where the beacons are written and read: the own folder, then the option's. */
71let dirs: string[] = []
72let isDirty = true
73let isBeating = false
74let wroteAt = 0
75let scannedAt = 0
76/** The beacons read so far, by path, with the mtime they were read at. */
77const cache = new Map<string, { mtimeMs: number; beacon: Beacon | undefined }>()
78/** The tools running now, by call, for the one the beacon names. */
79const running = new Map<string, string>()
80
81let startSweep: (() => void) | undefined
82let sweep: Timer | undefined
83let beat: Timer | undefined
84let mounted: { columns: number; rows: number } | undefined
85let listedAt = 0
86let denials = 0
87
88// The conductor's. Made by session.start over its own `$`: the panes' buttons,
89// the timer and the receive hook call them.
90let root = ''
91let file = ''
92let fileText: string | undefined
93let watch: Timer | undefined
94let isPlanning = false
95/** When a draft of the tasks file was last asked for; the button waits a while. */
96let draftedAt = 0
97let startWatch: (() => void) | undefined
98let refreshNow: (() => Promise<void>) | undefined
99let give: ((move: Move) => Promise<void>) | undefined
100let autoStep: (() => Promise<void>) | undefined
101
102function selfBeacon(now: number, say: Texts): Beacon {
103 const { state, detail } = stateOf(current, say)
104
105 const beacon: Beacon = { v: 1, sessionId: selfId, name: selfName, branch: selfBranch, state, detail, since: current.since, beatAt: now }
106
107 return isAvailable ? { ...beacon, isAvailable } : beacon
108}
109
110/** Keeps the module's copy, which the timers read, and returns it for the state. */
111function mark(next: Activity): Activity {
112 current = next
113 isDirty = true
114
115 return next
116}
117
118/** As `mark`, for whether this session takes tasks from a conductor. */
119function markAvailable(next: boolean): boolean {
120 isAvailable = next
121 isDirty = true
122
123 return next
124}
125
126/** The peers' fields a redraw depends on: the beats alone change nothing drawn. */
127function shapeOf(list: readonly Beacon[]): string {
128 return JSON.stringify(list.map(({ beatAt: _, ...rest }) => rest))
129}
130
131function hex(color: number): string {
132 return `#${color.toString(16).padStart(6, '0')}`
133}
134
135/** The radar's size for a body this wide, and whether the list fits beside it. */
136function layout(bodyColumns: number): { columns: number; rows: number; isBeside: boolean } {
137 if (bodyColumns >= 64) return { columns: 28, rows: 14, isBeside: true }
138 const columns = Math.max(12, Math.min(24, Math.floor((bodyColumns - 2) / 2) * 2))
139
140 return { columns, rows: columns / 2, isBeside: false }
141}
142
143function stopSweep(): void {
144 sweep?.cancel()
145 sweep = undefined
146}
147
148export const register: Register = (on, options) => {
149 const say = sayOf(options.language)
150 const tasksFile = String(options.tasksFile ?? '').trim() || 'TASKS.md'
151 const isolation = String(options.isolation ?? '').trim().toLowerCase() === 'shared' ? 'shared' : 'worktree'
152
153 on('session.start', async ($, e, next) => {
154 // The sweep lives on session.start's timers; a draw of the pane starts it.
155 startSweep = () => {
156 sweep ??= $.clock.every(FRAME_MS, () => {
157 if (!mounted) return
158 const now = Date.now()
159 const cells = frame(mounted.columns, mounted.rows, now, blips([selfBeacon(now, say), ...peers], now))
160 void $.ui.blit({ requestId: PANE, key: SCOPE, cells }).then(result => {
161 denials = result.deny ? denials + 1 : 0
162 // A second of refusals: the pane is gone; its next draw starts the sweep again.
163 if (denials >= 1000 / FRAME_MS) stopSweep()
164 })
165 if (now - listedAt >= LIST_MS) {
166 listedAt = now
167 $.ui.invalidate('ui.render')
168 }
169 })
170 }
171
172 await $.command.register({ name: 'radar', description: say.command, argumentHint: '[available | conductor]' })
173 // What the beat writes, as the state holds it across a reload.
174 current = await read($, activity)
175 isAvailable = await read($, availableAtom)
176
177 root = ((await $.session.repo())?.root ?? (await $.session.cwd())).replace(/\\/g, '/')
178 selfName = root.split('/').filter(Boolean).pop() ?? selfName
179 const git = await $.process.run(['git', 'branch', '--show-current'], { cwd: root }).catch(() => undefined)
180 selfBranch = git?.exitCode === 0 ? git.stdout.trim() : ''
181 selfId = await $.session.id()
182 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
183 // The own folder and every other configuration's the option names: the
184 // beacon is written to each, so one side's option makes both see each
185 // other; a session found in several is drawn once, by its id (peersOf).
186 const own = beaconDir({ configDir: await $.env.get('CLAUDE_CONFIG_DIR'), home })
187 dirs = beaconDirs(own, options.extraConfigDirs, home)
188
189 // The beacon: written on a change (at most once a tick) and every BEAT_MS;
190 // the others' read every SCAN_MS, a file again only once it changed.
191 beat?.cancel()
192 beat = $.clock.every(TICK_MS, () => {
193 if (dirs.length === 0 || isBeating) return
194 isBeating = true
195 void (async () => {
196 const now = Date.now()
197 const id = await $.session.id()
198 if (id !== selfId) {
199 selfId = id // a /clear goes on under a new id
200 isDirty = true
201 }
202 if (isDirty || now - wroteAt >= BEAT_MS) {
203 isDirty = false
204 wroteAt = now
205 const text = encode(selfBeacon(now, say))
206 for (const at of dirs) await $.fs.write(`${at}/${fileOf(selfId)}`, text).catch(() => undefined)
207 }
208 if (now - scannedAt < SCAN_MS) return
209 scannedAt = now
210 const paths = new Set<string>()
211 for (const at of dirs) {
212 const entries = await $.fs.list(at).catch(() => [])
213 for (const entry of entries) {
214 if (entry.kind !== 'file' || !entry.name.endsWith('.json') || now - entry.mtimeMs >= STALE_MS) continue
215 const path = `${at}/${entry.name}`
216 paths.add(path)
217 if (cache.get(path)?.mtimeMs === entry.mtimeMs) continue
218 const text = await $.fs.read(path).catch(() => undefined)
219 cache.set(path, { mtimeMs: entry.mtimeMs, beacon: typeof text === 'string' ? decode(text) : undefined })
220 }
221 }
222 for (const path of cache.keys()) if (!paths.has(path)) cache.delete(path)
223 const seen = peersOf([...cache.values()].map(one => one.beacon), selfId, now)
224 if (shapeOf(seen) !== shapeOf(peers)) await update($, peersAtom, () => seen)
225 peers = seen
226 })()
227 .catch(() => undefined)
228 .finally(() => {
229 isBeating = false
230 })
231 })
232
233 // The conductor. What it gave out is kept per repository across sessions,
234 // so a session that restarts never hands a task out twice.
235 const storedFile = await $.store.get(fileKey(root))
236 if (typeof storedFile === 'string' && (await read($, chosenAtom)) === '') await update($, chosenAtom, () => storedFile)
237 if ((await read($, assignmentsAtom)).length === 0) {
238 const kept = assignmentsOf(await $.store.get(assignmentsKey(root)))
239 if (kept.length > 0) await update($, assignmentsAtom, () => kept)
240 }
241
242 // Looks for the file where it was named, else in the usual places; reads it
243 // again once it changed, and drops the tasks it no longer holds.
244 const refresh = async (isForced = false) => {
245 let found = ''
246 let body: string | undefined
247 for (const one of candidatesOf(await read($, chosenAtom), tasksFile)) {
248 const path = resolveIn(root, one)
249 const text = await $.fs.read(path).catch(() => undefined)
250 if (typeof text === 'string') {
251 found = path
252 body = text
253 break
254 }
255 }
256 if (!isForced && found === file && body === fileText) return
257 file = found
258 fileText = body
259 await update($, fileAtom, () => found)
260 await update($, tasksAtom, () => (body === undefined ? [] : parseTasks(body)))
261 }
262 refreshNow = () => refresh(true)
263
264 // Where a task is worked on: a worktree of its own, on a branch of its own,
265 // started from what it builds on (baseOf); or, `isolation: shared`, here.
266 const placeOf = async (task: Task): Promise<Place | { why: string }> => {
267 if (isolation !== 'worktree') return { dir: root, from: selfName }
268 const { branch, path } = worktreeOf(root, task.id)
269 const { base, merges } = baseOf(task, await read($, assignmentsAtom))
270 if (!(await $.fs.exists(path).catch(() => false))) {
271 const git = (argv: string[]) =>
272 $.process.run(['git', ...argv], { cwd: root }).catch((error: unknown) => ({ exitCode: 1, stderr: String(error) }))
273 const made = await git(['worktree', 'add', '-b', branch, path, ...(base ? [base] : [])])
274 // The branch may be left from an earlier round: check it out as it is.
275 const again = made.exitCode === 0 ? made : await git(['worktree', 'add', path, branch])
276 if (again.exitCode !== 0) return { why: (made.stderr || again.stderr || 'git worktree add').trim().split(/\r?\n/).pop() ?? '' }
277 }
278
279 return { dir: path, branch, merges, from: selfName }
280 }
281
282 give = async move => {
283 const place = await placeOf(move.task)
284 if ('why' in place) {
285 $.ui.toast(say.c.noWorktree(move.task.id, place.why))
286
287 return
288 }
289 const sent = await $.session.send({ to: { sessionId: move.sessionId }, text: brief(move.task, place) })
290 if (!sent.isDelivered) {
291 $.ui.toast(say.c.notSent(move.task.id, move.name, sent.reason))
292
293 return
294 }
295 const now = Date.now()
296 const given: Assignment = { taskId: move.task.id, sessionId: move.sessionId, name: move.name, status: 'working', note: '', since: now }
297 if (place.branch) given.branch = place.branch
298 const list = await update($, assignmentsAtom, all => [...all, given].slice(-KEPT))
299 await $.store.set(assignmentsKey(root), list)
300 $.ui.toast(say.c.sentTo(move.task.id, move.name))
301 }
302
303 autoStep = async () => {
304 if (isPlanning || !(await read($, autoAtom))) return
305 isPlanning = true
306 try {
307 const moves = plan(await read($, tasksAtom), await read($, assignmentsAtom), sessionsOf(peers))
308 for (const move of moves) await give?.(move)
309 } finally {
310 isPlanning = false
311 }
312 }
313
314 startWatch = () => {
315 watch ??= $.clock.every(WATCH_MS, () => {
316 void refresh()
317 .then(() => autoStep?.())
318 .catch(() => undefined)
319 $.ui.invalidate('ui.render') // the elapsed times, the spinners
320 })
321 void refresh()
322 }
323 // A reload, or a session that had the conductor open: watch again.
324 if ((await read($, fileAtom)) !== '' || (await read($, chosenAtom)) !== '') startWatch()
325
326 return next(e)
327 })
328
329 on('session.end', async ($, e, next) => {
330 // A /clear goes on under a new id: the old beacon ends, the beat goes on.
331 if (e.reason !== 'clear') beat?.cancel()
332 // Taking tasks is this conversation's say: the next one starts without.
333 else {
334 await update($, availableAtom, () => markAvailable(false))
335 await update($, taskAtom, () => null)
336 }
337 const now = Date.now()
338 const last = encode({ ...selfBeacon(now, say), sessionId: e.sessionId, endedAt: now })
339 for (const at of dirs) await $.fs.write(`${at}/${fileOf(e.sessionId)}`, last).catch(() => undefined)
340
341 return next(e)
342 })
343
344 on('command.run', { command: 'radar' }, async ($, e) => {
345 const arg = e.args.trim().toLowerCase()
346 if (arg === 'available') {
347 const now = await update($, availableAtom, value => markAvailable(!value))
348
349 return { text: now ? say.nowAvailable : say.nowUnavailable }
350 }
351 if (arg === 'conductor') {
352 startWatch?.()
353 await $.ui.open({ id: PANE, title: 'Radar' })
354
355 return { text: say.c.opened }
356 }
357 await $.ui.open({ id: PANE, title: 'Radar' })
358
359 return { text: say.opened }
360 })
361
362 on('session.receive', async ($, e, next) => {
363 if (e.agentId !== undefined) return next(e)
364
365 // A session's report on a task this one gave out: taken before the model
366 // sees it, so the conductor's context pays for none of it.
367 const isPeer = e.origin.kind === 'peer' || e.origin.kind === 'peer-send-message'
368 const report = isPeer ? reportOf(e.text) : undefined
369 const id = report && ((await read($, tasksAtom)).find(t => t.id.toLowerCase() === report.taskId.toLowerCase())?.id ?? report.taskId)
370 const held = id ? assignmentOf(await read($, assignmentsAtom), id) : undefined
371 if (report && held && held.status !== 'delivered') {
372 const list = await update($, assignmentsAtom, all =>
373 [...all, { ...held, status: report.status, note: report.note, since: Date.now() }].slice(-KEPT),
374 )
375 await $.store.set(assignmentsKey(root), list)
376 if (report.status === 'delivered' && file !== '') {
377 const text = await $.fs.read(file).catch(() => undefined)
378 const ticked = typeof text === 'string' ? tickTask(text, held.taskId) : undefined
379 if (ticked !== undefined) await $.fs.write(file, ticked)
380 }
381 $.ui.toast(say.c.reported(held.taskId, held.name, say.c[report.status], report.note))
382 void autoStep?.()
383
384 return { consumed: `radar conductor: ${report.status} ${held.taskId} from ${held.name}` }
385 }
386
387 // A conductor's brief, seen on its way to the model: the band says which
388 // task this session works on, and for whom. The message goes on unchanged.
389 const handed = briefOf(e.text, Date.now())
390 if (handed) await update($, taskAtom, () => handed)
391
392 return next(e)
393 }).catch(($, e, next) => next(e))
394
395 // The report that closes the task, once it went out.
396 on('session.send', async ($, e, next) => {
397 const sent = await next(e)
398 const task = await read($, taskAtom)
399 if (sent.isDelivered && e.agentId === undefined && task && closes(e.text, task.id)) {
400 await update($, taskAtom, () => null)
401 }
402
403 return sent
404 }).catch(($, e, next) => next(e))
405
406 // The band: the questions this session's conductor waits on you for (a toast
407 // would be gone in four seconds), then, in a session that takes tasks, the
408 // task it works on or the fact that it takes them.
409 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
410 if (e.props.hasSurvey) return next(e)
411 const asked = latest(await read($, assignmentsAtom))
412 .filter(a => a.status === 'question')
413 .slice(0, 2)
414 const task = await read($, taskAtom)
415 const isOpen = await read($, availableAtom)
416 if (asked.length === 0 && !task && !isOpen) return next(e)
417 const ui = $.ui.resolve(e)
418 const { Box, Text, Button } = ui
419
420 return (
421 <Box flexDirection="column">
422 {asked.map(a => drawQuestion(ui, a, () => void $.ui.open({ id: PANE, title: 'Radar' }), say.c))}
423 {task ? (
424 <Text wrap="truncate">
425 <Text color="#d2a8ff">⛑ </Text>
426 <Text bold>{task.id}</Text> {task.title}
427 <Text dimColor>
428 {' '}
429 · {say.handedBy(task.from)}
430 </Text>
431 </Text>
432 ) : isOpen ? (
433 <Box flexDirection="row" gap={2}>
434 <Text wrap="truncate">
435 <Text color="#d2a8ff">⛑ </Text>
436 {say.takesTasks}
437 </Text>
438 <Button key="band-leave" label={say.stop} onPress={() => update($, availableAtom, value => markAvailable(!value))} />
439 </Box>
440 ) : null}
441 </Box>
442 )
443 })
444
445 on('ui.close', { id: PANE }, async ($, e, next) => {
446 stopSweep()
447 mounted = undefined
448
449 return next(e)
450 }).catch(($, e, next) => next(e))
451
452 on('turn.start', async ($, e, next) => {
453 await update($, activity, () => mark({ tool: null, isTurn: true, wait: null, since: Date.now() }))
454
455 return next(e)
456 })
457
458 on('turn.complete', async ($, e, next) => {
459 running.clear()
460 await update($, activity, () => mark({ tool: null, isTurn: false, wait: null, since: Date.now() }))
461
462 return next(e)
463 })
464
465 on('tool.call', async ($, e, next) => {
466 running.set(e.tool_use_id, e.tool)
467 const wait = e.tool === 'AskUserQuestion' ? 'answer' : null
468 await update($, activity, () => mark({ tool: e.tool, isTurn: true, wait, since: Date.now() }))
469 try {
470 return await next(e)
471 } finally {
472 running.delete(e.tool_use_id)
473 const still = [...running.values()].pop() ?? null
474 await update($, activity, () => mark({ tool: still, isTurn: true, wait: null, since: Date.now() }))
475 }
476 }).catch(($, e, next) => next(e))
477
478 // One pane, two frames as `dash` draws them: the radar and its sessions,
479 // then the conductor.
480 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
481 const ui = $.ui.resolve(e)
482 const { Box, Text, Button } = ui
483 const now = Date.now()
484 current = await read($, activity)
485 isAvailable = await read($, availableAtom)
486 peers = await read($, peersAtom)
487 const task = await read($, taskAtom)
488 const seen = [selfBeacon(now, say), ...peers]
489 // The frame takes two columns of border and two of padding.
490 const size = layout(e.props.bodyColumns - 4)
491
492 const list = (
493 <Box flexDirection="column" flexShrink={1}>
494 {seen.map((b, i) => (
495 <Box flexDirection="column">
496 <Text wrap="truncate">
497 <Text color={hex(COLOR[b.state])}>●</Text> <Text bold={i === 0}>{b.name}</Text>
498 {i === 0 ? <Text color={RADAR_COLOR}>{say.you}</Text> : null}
499 {b.isAvailable ? <Text color={CONDUCTOR_COLOR}> ⛑</Text> : null}
500 <Text dimColor> {b.branch}</Text>
501 </Text>
502 <Text wrap="truncate">
503 {' '}
504 <Text color={hex(COLOR[b.state])}>
505 {b.state === 'busy' ? SPIN[Math.floor(now / 100) % SPIN.length] : b.state === 'waiting' ? '◌' : '·'}
506 </Text>
507 <Text dimColor>
508 {' '}
509 {b.detail} · {elapsed(now - b.since)}
510 </Text>
511 </Text>
512 </Box>
513 ))}
514 {peers.length === 0 ? <Text dimColor>{say.alone}</Text> : null}
515 </Box>
516 )
517
518 // This session's own part in the conductor's work: whether it takes tasks,
519 // and the one it was handed.
520 const self = (
521 <Box flexDirection="column" marginTop={1}>
522 {task ? (
523 <Text wrap="truncate">
524 <Text color={CONDUCTOR_COLOR}>⛑ </Text>
525 <Text bold>{task.id}</Text> {task.title}
526 <Text dimColor> · {say.handedBy(task.from)}</Text>
527 </Text>
528 ) : null}
529 <Box flexDirection="row" flexWrap="wrap" gap={1}>
530 <Button
531 key="available"
532 label={isAvailable ? say.leave : say.join}
533 variant={isAvailable ? 'secondary' : 'primary'}
534 hover={lit('available', CONDUCTOR_COLOR)}
535 onPress={() => update($, availableAtom, value => markAvailable(!value))}
536 />
537 </Box>
538 </Box>
539 )
540
541 const isTerminal = e.surface === 'terminal'
542 let scope: RenderElement | null = null
543 if (isTerminal) {
544 const { Raster } = $.ui.resolve(e)
545 mounted = { columns: size.columns, rows: size.rows }
546 denials = 0
547 startSweep?.()
548 scope = <Raster key={SCOPE} columns={size.columns} rows={size.rows} cells={frame(size.columns, size.rows, now, blips(seen, now))} />
549 } else {
550 mounted = undefined
551 }
552
553 const radar = (
554 <Box flexDirection="column">
555 <Box key="head:radar" flexDirection="row" gap={1}>
556 <Text bold color={RADAR_COLOR}>
557 {say.title}
558 </Text>
559 <Text dimColor>{say.sessions(seen.length)}</Text>
560 </Box>
561 <Box key="frame:radar" flexDirection="column" borderStyle="round" borderColor={RADAR_COLOR} paddingX={1}>
562 <Box flexDirection={size.isBeside && scope ? 'row' : 'column'} gap={2}>
563 {scope}
564 {list}
565 </Box>
566 {isTerminal ? null : <Text dimColor>{say.terminalOnly}</Text>}
567 {self}
568 </Box>
569 </Box>
570 )
571
572 const chosen = await read($, chosenAtom)
573
574 // The file: named in the pane, kept for this repository across sessions.
575 const choose = async (value: string) => {
576 const path = value.trim()
577 await update($, chosenAtom, () => path)
578 await $.store.set(fileKey(root), path)
579 startWatch?.()
580 await refreshNow?.()
581 }
582 // Every change to who holds what is kept for the next session too.
583 const change = async (fn: (all: Assignment[]) => Assignment[]) => {
584 const list = await update($, assignmentsAtom, all => fn(all).slice(-KEPT))
585 await $.store.set(assignmentsKey(root), list)
586 }
587
588 const conductor = drawConductor(
589 ui,
590 {
591 now,
592 root,
593 option: tasksFile,
594 tasks: await read($, tasksAtom),
595 assignments: await read($, assignmentsAtom),
596 isAuto: await read($, autoAtom),
597 found: await read($, fileAtom),
598 chosen,
599 sessions: sessionsOf(await read($, peersAtom)),
600 isDrafting: now - draftedAt < DRAFT_WAIT_MS,
601 },
602 {
603 choose: value => void choose(value),
604 draft: () => {
605 draftedAt = Date.now()
606 void choose(DRAFT_PATH).then(async () => {
607 await $.prompt.submit({ text: draft(DRAFT_PATH) })
608 $.ui.toast(say.c.drafting(DRAFT_PATH))
609 })
610 },
611 give: move => void give?.(move),
612 answer: (a, text) =>
613 void (async () => {
614 const sent = await $.session.send({ to: { sessionId: a.sessionId }, text: answer(a.taskId, text) })
615 if (!sent.isDelivered) {
616 $.ui.toast(say.c.notSent(a.taskId, a.name, sent.reason))
617
618 return
619 }
620 await change(all => [...all, { ...a, status: 'working' as const, note: '', since: Date.now() }])
621 $.ui.toast(say.c.answered(a.taskId, a.name))
622 })(),
623 requeue: taskId => void change(all => all.filter(one => one.taskId !== taskId)),
624 toggleAuto: () =>
625 void (async () => {
626 await update($, autoAtom, value => !value)
627 await autoStep?.()
628 })(),
629 reload: () => {
630 startWatch?.()
631 void refreshNow?.()
632 },
633 },
634 say.c,
635 )
636
637 return (
638 <Box flexDirection="column">
639 {radar}
640 {conductor}
641 </Box>
642 )
643 })
644}
645hooks/beacon.ts 117 lines1/**
2 * The beacon: one small JSON file per session under
3 * `<config dir>/radar/beacons/`, rewritten while the session lives, read by
4 * every other session's radar.
5 *
6 * The plugin's file system has no delete, so a session that ends writes its
7 * beacon one last time with `endedAt`, and a session that crashed is told by
8 * its beat going stale.
9 *
10 * @module beacon
11 */
12
13import type { Beacon, State } from '../types'
14
15export type { Beacon, State }
16
17/** How often a live session rewrites its beacon, at the least. */
18export const BEAT_MS = 5000
19
20/** A beacon this old is a session gone without saying so. */
21export const STALE_MS = 30_000
22
23const STATES: readonly State[] = ['busy', 'waiting', 'idle']
24
25/**
26 * The folder the beacons live in: under `CLAUDE_CONFIG_DIR` when set, else
27 * under `~/.claude`, so sessions of one configuration see each other.
28 *
29 * @returns the folder, with `/` separators, or undefined with no home to put it in
30 */
31export function beaconDir(env: { configDir?: string; home?: string; userProfile?: string }): string | undefined {
32 const base = env.configDir || (env.home || env.userProfile ? `${env.home || env.userProfile}/.claude` : '')
33
34 return base === '' ? undefined : `${base.replace(/\\/g, '/').replace(/\/+$/, '')}/radar/beacons`
35}
36
37/**
38 * The folders the beacons are written to and read from: the session's own, then one per
39 * configuration folder `extra` names (separated by `;`, `,` or a line break,
40 * never `:`, which a Windows path holds), `~` standing for the home folder.
41 * A folder named twice, in any case, is read once.
42 *
43 * @param own the session's own beacon folder (beaconDir)
44 * @param extra the `extraConfigDirs` option, as typed
45 * @param home the home folder `~` stands for
46 */
47export function beaconDirs(own: string | undefined, extra: unknown, home?: string): string[] {
48 const dirs = own === undefined ? [] : [own]
49 for (const one of String(extra ?? '').split(/[;,\r\n]/)) {
50 const path = one.trim().replace(/^~(?=$|[\\/])/, () => home ?? '~')
51 if (path === '' || path.startsWith('~')) continue
52 const dir = beaconDir({ configDir: path })
53 if (dir !== undefined) dirs.push(dir)
54 }
55 const seen = new Set<string>()
56
57 return dirs.filter(dir => {
58 const key = dir.toLowerCase()
59 if (seen.has(key)) return false
60 seen.add(key)
61
62 return true
63 })
64}
65
66/** The file one session's beacon is written to: its id, made safe for a name. */
67export function fileOf(sessionId: string): string {
68 return `${sessionId.replace(/[^A-Za-z0-9_-]/g, '_')}.json`
69}
70
71export function encode(beacon: Beacon): string {
72 return JSON.stringify(beacon)
73}
74
75/** A beacon from a file's text, or undefined for anything that is not one. */
76export function decode(text: string): Beacon | undefined {
77 let raw: unknown
78 try {
79 raw = JSON.parse(text)
80 } catch {
81 return undefined
82 }
83 if (typeof raw !== 'object' || raw === null) return undefined
84 const b = raw as Record<string, unknown>
85 const isText = (k: string) => typeof b[k] === 'string'
86 const isTime = (k: string) => typeof b[k] === 'number' && Number.isFinite(b[k])
87 if (b.v !== 1 || !['sessionId', 'name', 'branch', 'detail'].every(isText)) return undefined
88 if (!['since', 'beatAt'].every(isTime) || !STATES.includes(b.state as State)) return undefined
89 if (b.endedAt !== undefined && !isTime('endedAt')) return undefined
90 if (b.isAvailable !== undefined && typeof b.isAvailable !== 'boolean') return undefined
91
92 return raw as Beacon
93}
94
95/** Whether the session behind a beacon still runs: not ended, and beating. */
96export function isLive(beacon: Beacon, now: number): boolean {
97 return beacon.endedAt === undefined && now - beacon.beatAt < STALE_MS
98}
99
100/**
101 * The beacons worth showing: not this session's, the newest copy of each
102 * session (one per folder it writes to), and that copy live, so a session
103 * that ended in one folder is gone from all.
104 */
105export function peersOf(beacons: readonly (Beacon | undefined)[], selfId: string, now: number): Beacon[] {
106 const byId = new Map<string, Beacon>()
107 for (const b of beacons) {
108 if (!b || b.sessionId === selfId) continue
109 const seen = byId.get(b.sessionId)
110 if (!seen || seen.beatAt < b.beatAt) byId.set(b.sessionId, b)
111 }
112
113 return [...byId.values()]
114 .filter(b => isLive(b, now))
115 .sort((a, b) => a.name.localeCompare(b.name) || a.sessionId.localeCompare(b.sessionId))
116}
117hooks/conductor.tsx 277 lines1/**
2 * The conductor's drawing: its frame in the radar's pane and its band rows,
3 * from plain values and the acts the register hands it. No hook, no `$`:
4 * what a press does is the caller's.
5 *
6 * @module conductor
7 */
8
9import type { EngineInterface, RenderElement } from 'claude-code'
10
11import type { Assignment, Status, Task } from '../types'
12import type { ConductorTexts } from './say'
13import { elapsed } from './sightings'
14import { assignmentOf, blockersOf, candidatesOf, isBusy, plan, shownFrom } from './tasks'
15
16export type Elements = ReturnType<EngineInterface['ui']['resolve']>
17
18/** A session as the conductor sees it: radar's, and whether it takes tasks. */
19export type Session = { sessionId: string; name: string; state: string; isAvailable: boolean }
20
21export type Move = { task: Task; sessionId: string; name: string }
22
23/** Where a draft of the tasks file is asked for. */
24export const DRAFT_PATH = 'docs/TASKS.md'
25
26/** The conductor's frame, its title and its buttons' hover. */
27export const CONDUCTOR_COLOR = '#c084fc'
28
29export const STATUS_COLOR: Record<Status, string> = {
30 working: '#ffa657',
31 question: '#d2a8ff',
32 blocked: '#ff7b72',
33 delivered: '#7ee787',
34}
35
36const QUEUE_COLOR = '#79c0ff'
37const HUMAN_COLOR = '#f2cc60'
38const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
39
40/** A Button's hover: lit in its own color, scoped to its key. */
41export const lit = (key: string, color: string) => ({ scope: key.slice(0, 64), color })
42
43/** The newest assignment of each task, newest first. */
44export function latest(assignments: readonly Assignment[]): Assignment[] {
45 const byTask = new Map<string, Assignment>()
46 for (const one of assignments) byTask.set(one.taskId, one)
47
48 return [...byTask.values()].sort((a, b) => b.since - a.since)
49}
50
51export function sessionsOf(
52 peers: readonly { sessionId: string; name: string; state: string; isAvailable?: boolean }[] | undefined,
53): Session[] {
54 return (peers ?? []).map(({ sessionId, name, state, isAvailable }) => ({ sessionId, name, state, isAvailable: isAvailable === true }))
55}
56
57export type ConductorView = {
58 now: number
59 root: string
60 /** The `tasksFile` option. */
61 option: string
62 tasks: Task[]
63 assignments: Assignment[]
64 isAuto: boolean
65 /** The file read, absolute, or empty while none is found. */
66 found: string
67 /** The file named in the pane, or empty. */
68 chosen: string
69 sessions: Session[]
70 /** A draft was asked for a moment ago: no second button. */
71 isDrafting: boolean
72}
73
74export type ConductorActs = {
75 choose: (path: string) => void
76 draft: () => void
77 give: (move: Move) => void
78 answer: (assignment: Assignment, text: string) => void
79 requeue: (taskId: string) => void
80 toggleAuto: () => void
81 reload: () => void
82}
83
84/** The conductor's title row and frame, as `dash` draws its own. */
85export function drawConductor(ui: Elements, view: ConductorView, act: ConductorActs, say: ConductorTexts): RenderElement {
86 const { Box, Text, Button } = ui
87 const Input = 'Input' in ui ? ui.Input : undefined
88 const { now, root, tasks, assignments, sessions, found, chosen } = view
89 const held = latest(assignments)
90 const moves = plan(tasks, assignments, sessions)
91 const free = sessions.filter(s => s.isAvailable && !isBusy(s.sessionId, assignments))
92 const open = tasks.filter(t => !t.isDone && !assignmentOf(assignments, t.id))
93 const human = open.filter(t => t.isHuman)
94 const queued = open.filter(t => !t.isHuman && blockersOf(t, tasks).length === 0)
95 const waiting = open.filter(t => !t.isHuman && blockersOf(t, tasks).length > 0)
96 const done = tasks.filter(t => t.isDone).length
97
98 const head = (
99 <Box key="head:conductor" flexDirection="row" gap={1} marginTop={1}>
100 <Text bold color={CONDUCTOR_COLOR}>
101 {say.title}
102 </Text>
103 <Text dimColor wrap="truncate">
104 {found ? `${shownFrom(root, found)} · ${done}/${tasks.length} ✓ · ` : ''}
105 {say.sessions(sessions.length)} · {view.isAuto ? say.auto : say.manual}
106 </Text>
107 </Box>
108 )
109
110 // The file: named here (Enter), or looked for where it usually is.
111 const picker = Input ? (
112 <Input
113 key="tasks-file"
114 label={say.fileLabel}
115 value={chosen || (found ? shownFrom(root, found) : '')}
116 placeholder={candidatesOf('', view.option).join(' · ')}
117 onSubmit={value => act.choose(value)}
118 />
119 ) : null
120
121 if (found === '') {
122 return (
123 <Box flexDirection="column">
124 {head}
125 <Box key="frame:conductor" flexDirection="column" borderStyle="round" borderColor={CONDUCTOR_COLOR} paddingX={1}>
126 {picker}
127 <Text dimColor>{say.noFile(candidatesOf(chosen, view.option).join(', '))}</Text>
128 {view.isDrafting ? (
129 <Text color={CONDUCTOR_COLOR}>{say.drafting(DRAFT_PATH)}</Text>
130 ) : (
131 <Box flexDirection="row">
132 <Button
133 key="draft"
134 label={say.draft(DRAFT_PATH)}
135 variant="primary"
136 hover={lit('draft', CONDUCTOR_COLOR)}
137 onPress={() => act.draft()}
138 />
139 </Box>
140 )}
141 </Box>
142 </Box>
143 )
144 }
145
146 return (
147 <Box flexDirection="column">
148 {head}
149 <Box key="frame:conductor" flexDirection="column" borderStyle="round" borderColor={CONDUCTOR_COLOR} paddingX={1}>
150 {picker}
151 {held.map(a => {
152 const task = tasks.find(t => t.id === a.taskId)
153 const color = STATUS_COLOR[a.status]
154 const mark =
155 a.status === 'working'
156 ? SPIN[Math.floor(now / 100) % SPIN.length]
157 : a.status === 'delivered'
158 ? '✓'
159 : a.status === 'question'
160 ? '?'
161 : '✕'
162 const row = (
163 <Text wrap="truncate" dimColor={a.status === 'delivered'}>
164 <Text color={color}>●</Text> <Text bold>{a.taskId}</Text> {task?.title ?? ''} <Text dimColor>→</Text> {a.name}{' '}
165 <Text color={color}>
166 {mark} {say[a.status]}
167 </Text>
168 <Text dimColor> · {elapsed(now - a.since)}</Text>
169 {a.branch ? <Text color={QUEUE_COLOR}> ⎇ {a.branch}</Text> : null}
170 </Text>
171 )
172
173 // A question waits on the person: its own frame, with the answer field.
174 if (a.status === 'question') {
175 return (
176 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
177 {row}
178 <Text wrap="wrap">
179 <Text color={color}>✉ </Text>
180 {a.note}
181 </Text>
182 {Input ? (
183 <Input
184 key={`answer-${a.taskId}`}
185 label={say.answerLabel(a.name)}
186 placeholder={say.answerPlaceholder}
187 onSubmit={value => {
188 if (value.trim() !== '') act.answer(a, value.trim())
189 }}
190 />
191 ) : null}
192 </Box>
193 )
194 }
195
196 return (
197 <Box flexDirection="column">
198 {row}
199 {a.note !== '' ? (
200 <Text dimColor wrap="truncate">
201 {' ✉ '}
202 {a.note}
203 </Text>
204 ) : null}
205 {a.status === 'blocked' ? (
206 <Box flexDirection="row" paddingLeft={2}>
207 <Button
208 key={`requeue-${a.taskId}`}
209 label={say.requeue(a.taskId)}
210 hover={lit(`requeue-${a.taskId}`, color)}
211 onPress={() => act.requeue(a.taskId)}
212 />
213 </Box>
214 ) : null}
215 </Box>
216 )
217 })}
218 {queued.length > 0 ? (
219 <Text wrap="truncate">
220 <Text dimColor>{say.queue}</Text> <Text color={QUEUE_COLOR}>{queued.map(t => t.id).join(' ')}</Text>
221 </Text>
222 ) : null}
223 {waiting.map(t => (
224 <Text wrap="truncate">
225 <Text dimColor>{say.waitsOn(blockersOf(t, tasks).join(', '))}</Text> {t.id}
226 </Text>
227 ))}
228 {human.length > 0 ? (
229 <Text wrap="truncate">
230 <Text color={HUMAN_COLOR}>{say.human}</Text> {human.map(t => t.id).join(' ')}
231 </Text>
232 ) : null}
233 {sessions.length === 0 ? <Text dimColor>{say.noSession}</Text> : null}
234 {sessions.length > 0 && !sessions.some(s => s.isAvailable) ? <Text dimColor>{say.noneAvailable}</Text> : null}
235 {free.length > 0 ? (
236 <Text dimColor wrap="truncate">
237 {say.free} {free.map(s => `${s.name}${s.state === 'idle' ? '' : ` (${s.state})`}`).join(', ')}
238 </Text>
239 ) : null}
240 <Box flexDirection="row" flexWrap="wrap" gap={1}>
241 {moves.map(move => (
242 <Button
243 key={`give-${move.task.id}`}
244 label={say.give(move.task.id, move.name)}
245 variant="primary"
246 hover={lit(`give-${move.task.id}`, CONDUCTOR_COLOR)}
247 onPress={() => act.give(move)}
248 />
249 ))}
250 <Button
251 key="auto"
252 label={view.isAuto ? say.pauseAuto : say.startAuto}
253 hover={lit('auto', view.isAuto ? STATUS_COLOR.blocked : STATUS_COLOR.delivered)}
254 onPress={() => act.toggleAuto()}
255 />
256 <Button key="reload" label={say.reload} hover={lit('reload', QUEUE_COLOR)} onPress={() => act.reload()} />
257 </Box>
258 </Box>
259 </Box>
260 )
261}
262
263/** One band row: a session's question, waiting on the person, with the way to the answer field. */
264export function drawQuestion(ui: Elements, a: Assignment, onAnswer: () => void, say: ConductorTexts): RenderElement {
265 const { Box, Text, Button } = ui
266
267 return (
268 <Box flexDirection="row" gap={2}>
269 <Text wrap="truncate">
270 <Text color={STATUS_COLOR.question}>⛑ </Text>
271 {a.name} · {a.taskId} <Text color={STATUS_COLOR.question}>?</Text> {a.note}
272 </Text>
273 <Button key={`band-answer-${a.taskId}`} label={say.answerLabel(a.name)} onPress={onAnswer} />
274 </Box>
275 )
276}
277hooks/protocol.ts 86 lines1/**
2 * What goes to a session and what comes back: the brief that hands it a
3 * task, and the one-line reports it answers with.
4 *
5 * @module protocol
6 */
7
8import type { Status, Task } from '../types'
9
10export type Report = { status: Exclude<Status, 'working'>; taskId: string; note: string }
11
12const WORDS: Record<string, Report['status']> = {
13 DELIVERED: 'delivered',
14 BLOCKED: 'blocked',
15 QUESTION: 'question',
16}
17
18const REPORT = /(?:^|\n)\s*[*_`>]*\s*(DELIVERED|BLOCKED|QUESTION)\s+([A-Za-z][A-Za-z0-9]*-\d+|#\d+)\b[*_`]*\s*[:\-–—]?\s*([^\n]*)/i
19
20/**
21 * Where a task is worked on: a worktree of its own on a branch of its own,
22 * or the conductor's folder, shared with every other session.
23 */
24export type Place = {
25 dir: string
26 branch?: string
27 /** The branches of delivered prerequisites the worktree did not start from. */
28 merges?: string[]
29 from: string
30}
31
32/** The brief a session gets: the task, who hands it, where to work, and how to report. */
33export function brief(task: Task, place: Place): string {
34 const lines = [
35 `[conductor] Task ${task.id}: ${task.title}`,
36 `Handed out by ${place.from}.`,
37 ...(task.details ? ['', task.details] : []),
38 '',
39 `Work in ${place.dir}.`,
40 place.branch
41 ? `That folder is a git worktree of its own, on the branch ${place.branch}: commit your work there, on that branch, and do not merge it; name the commit in your report.`
42 : 'Other sessions may work in this folder at the same time: touch only the files this task needs.',
43 ...(place.branch && place.merges && place.merges.length > 0
44 ? [`It builds on work delivered on other branches: before you start, run git merge ${place.merges.join(' ')} there.`]
45 : []),
46 'When you are done, blocked, or need an answer, send ONE message back to the session that sent you this one, with SendMessage. Its first line must be exactly one of:',
47 `DELIVERED ${task.id}: <what you delivered, in one line: a commit, a PR>`,
48 `BLOCKED ${task.id}: <why, in one line>`,
49 `QUESTION ${task.id}: <your question>`,
50 ]
51
52 return lines.join('\n')
53}
54
55/** What this session's model is asked to write when there is no tasks file yet. */
56export function draft(path: string): string {
57 return [
58 `Write ${path} for the conductor plugin: the work that remains in this repository, split into tasks that separate Claude Code sessions can each take on alone, at the same time, without stepping on each other.`,
59 '',
60 'Draw them from this conversation first, then from the repository: its README and docs, TODO and FIXME comments, failing or missing tests, open items in any changelog or plan, recent commits. Leave out what is already done.',
61 '',
62 'The format, which the conductor parses:',
63 '- one Markdown checklist line per task: `- [ ] T-1 Short title`, ids T-1, T-2… in order;',
64 '- the lines indented under a task are its brief, sent as is to the session that takes it: what to do, which files, how to tell it is done;',
65 '- `(after T-1, T-2)` at the end of a task line names the tasks it waits for;',
66 '- a heading or a paragraph may stand between tasks; nothing else is read.',
67 '',
68 'Order the tasks so the ones nothing waits on come first, keep each one small enough for a single session, and write the titles and briefs in the language of this conversation. Then say in two lines what you put in the file.',
69 ].join('\n')
70}
71
72/** The answer to a session's question. */
73export function answer(taskId: string, text: string): string {
74 return `[conductor] ANSWER ${taskId}: ${text}\nCarry on with ${taskId}, and report the same way.`
75}
76
77/** A report in a message, wherever its line stands; undefined when it holds none. */
78export function reportOf(text: string): Report | undefined {
79 const found = REPORT.exec(text)
80 const word = found?.[1]?.toUpperCase()
81 const status = word ? WORDS[word] : undefined
82 if (!found || !status) return undefined
83
84 return { status, taskId: found[2] ?? '', note: (found[3] ?? '').trim() }
85}
86hooks/say.ts 133 lines1/**
2 * The words the pane draws, by language. A line French leaves out is drawn
3 * in English.
4 *
5 * @module say
6 */
7
8export const EN = {
9 title: '⌖ Control tower',
10 sessions: (n: number) => (n === 1 ? '1 session' : `${n} sessions`),
11 you: ' (you)',
12 thinking: 'thinking',
13 idle: 'idle',
14 awaitsAnswer: 'awaits an answer',
15 alone: 'No other session yet: one appears once radar runs in it, under this config folder or one extraConfigDirs names.',
16 terminalOnly: 'The animated radar draws in the terminal only.',
17 command: 'Opens the control tower: your sessions on a radar; available: takes tasks from a conductor, or stops; conductor: hands the tasks of a checklist to the sessions that take them',
18 opened: 'Control tower opened.',
19 join: '⛑ Take tasks',
20 leave: '⛑ Stop taking tasks',
21 nowAvailable: '⛑ This session takes tasks from a conductor now; /radar available again to stop.',
22 nowUnavailable: 'This session takes no more tasks from a conductor.',
23 takesTasks: 'Takes tasks from a conductor',
24 stop: 'Stop',
25 handedBy: (from: string) => (from ? `handed by ${from}` : 'handed by a conductor'),
26}
27
28export type Texts = typeof EN
29
30export const FR: Partial<Texts> = {
31 title: '⌖ Tour de contrôle',
32 you: ' (vous)',
33 thinking: 'réfléchit',
34 idle: 'inactive',
35 awaitsAnswer: 'attend une réponse',
36 alone: 'Aucune autre session : une session apparaît dès que radar y tourne, sous ce dossier de config ou un de ceux que nomme extraConfigDirs.',
37 terminalOnly: 'Le radar animé ne se dessine que dans le terminal.',
38 command: 'Ouvre la tour de contrôle : vos sessions sur un radar ; available : accepte les tâches d’un chef de chantier, ou arrête ; conductor : confie les tâches d’une liste aux sessions qui en acceptent',
39 opened: 'Tour de contrôle ouverte.',
40 join: '⛑ Accepter des tâches',
41 leave: '⛑ Ne plus accepter de tâches',
42 nowAvailable: '⛑ Cette session accepte désormais les tâches d’un chef de chantier ; /radar available à nouveau pour arrêter.',
43 nowUnavailable: 'Cette session n’accepte plus de tâches d’un chef de chantier.',
44 takesTasks: 'Accepte des tâches d’un chef de chantier',
45 stop: 'Se retirer',
46 handedBy: from => (from ? `confiée par ${from}` : 'confiée par un chef de chantier'),
47}
48
49// The conductor's words: its pane, its band rows and its toasts.
50
51export const CONDUCTOR_EN = {
52 title: '⛑ Conductor',
53 tasks: (n: number) => (n === 1 ? '1 task' : `${n} tasks`),
54 sessions: (n: number) => (n === 1 ? '1 session' : `${n} sessions`),
55 auto: 'auto',
56 manual: 'manual',
57 noFile: (looked: string) =>
58 `No tasks file (looked for ${looked}). Name one above, or write one: one checklist line per task, "- [ ] T-1 Title (after T-0)", indented lines for its brief.`,
59 fileLabel: 'Tasks file',
60 draft: (path: string) => `✎ Have Claude write ${path}`,
61 drafting: (path: string) => `✎ Claude writes ${path} from the repository and this conversation; the pane reads it once it is there.`,
62 noSession: 'No session on the radar: open one with radar loaded, it shows up here once idle.',
63 noneAvailable: 'No session takes tasks: type /radar available in one, or press ⛑ Take tasks in its radar pane.',
64 working: 'working',
65 question: 'question',
66 blocked: 'blocked',
67 delivered: 'delivered',
68 free: 'free:',
69 queue: 'queue:',
70 waitsOn: (ids: string) => `waits on ${ids}:`,
71 answerPlaceholder: 'Your answer…',
72 answerLabel: (name: string) => `↩ Answer ${name}`,
73 give: (id: string, name: string) => `⇢ ${id} → ${name}`,
74 requeue: (id: string) => `↺ Requeue ${id}`,
75 startAuto: '▶ Auto',
76 pauseAuto: '⏸ Pause',
77 reload: '↻ Reload',
78 human: 'yours (👤):',
79 open: '⛑ Conductor',
80 opened: 'Conductor opened.',
81 sentTo: (id: string, name: string) => `⇢ ${id} sent to ${name}`,
82 notSent: (id: string, name: string, why: string) => `⇢ ${id} not sent to ${name}: ${why}`,
83 reported: (id: string, name: string, status: string, note: string) => `${name} · ${id} ${status}${note ? `: ${note}` : ''}`,
84 answered: (id: string, name: string) => `↩ answer to ${name} on ${id} sent`,
85 noWorktree: (id: string, why: string) => `⇢ ${id} not sent: its worktree could not be made (${why})`,
86}
87
88export type ConductorTexts = typeof CONDUCTOR_EN
89
90export const CONDUCTOR_FR: Partial<ConductorTexts> = {
91 title: '⛑ Chef de chantier',
92 tasks: n => (n === 1 ? '1 tâche' : `${n} tâches`),
93 auto: 'auto',
94 manual: 'manuel',
95 noFile: looked =>
96 `Pas de fichier de tâches (cherché : ${looked}). Nommez-en un ci-dessus, ou écrivez-le : une case à cocher par tâche, « - [ ] T-1 Titre (after T-0) », les lignes en retrait pour sa consigne.`,
97 fileLabel: 'Fichier de tâches',
98 draft: path => `✎ Faire écrire ${path} par Claude`,
99 drafting: path => `✎ Claude écrit ${path} d’après le dépôt et cette conversation ; le pane le lira dès qu’il existe.`,
100 noSession: 'Aucune session sur le radar : ouvrez-en une avec radar chargé, elle apparaît ici dès qu’elle est inactive.',
101 noneAvailable: 'Aucune session n’accepte de tâches : tapez /radar available dans l’une d’elles, ou ⛑ Accepter des tâches dans son pane radar.',
102 working: 'en cours',
103 question: 'question',
104 blocked: 'bloquée',
105 delivered: 'livrée',
106 free: 'libres :',
107 queue: 'file :',
108 waitsOn: ids => `attend ${ids} :`,
109 answerPlaceholder: 'Votre réponse…',
110 answerLabel: name => `↩ Répondre à ${name}`,
111 requeue: id => `↺ Remettre ${id} en file`,
112 startAuto: '▶ Auto',
113 pauseAuto: '⏸ Geler',
114 reload: '↻ Relire',
115 human: 'à vous (👤) :',
116 open: '⛑ Chef de chantier',
117 opened: 'Chef de chantier ouvert.',
118 sentTo: (id, name) => `⇢ ${id} confiée à ${name}`,
119 notSent: (id, name, why) => `⇢ ${id} non envoyée à ${name} : ${why}`,
120 answered: (id, name) => `↩ réponse envoyée à ${name} sur ${id}`,
121 noWorktree: (id, why) => `⇢ ${id} non envoyée : son worktree n’a pas pu être créé (${why})`,
122}
123
124/** Every word the mod says: the radar's, and the conductor's under `c`. */
125export type Said = Texts & { c: ConductorTexts }
126
127/** The bundle a `language` option names; anything but `fr` is English. */
128export function sayOf(language: unknown): Said {
129 return String(language ?? '').trim().toLowerCase() === 'fr'
130 ? { ...EN, ...FR, c: { ...CONDUCTOR_EN, ...CONDUCTOR_FR } }
131 : { ...EN, c: CONDUCTOR_EN }
132}
133hooks/scope.ts 145 lines1// The radar screen as Raster cells: braille dots for the rings, the cross and
2// the sweep's fading trail, one glyph per blip. Pure: same inputs, same cells.
3
4/** A session on the screen: `angle` in radians clockwise from north, `range` 0 (center) to 1 (rim). */
5export type Blip = { angle: number; range: number; color: number }
6
7export const SWEEP_MS = 3200
8
9const TAU = Math.PI * 2
10const DEFAULT = 0x01000000
11const GRID = 0x2f6b40
12const TRAIL_DIM = 0x1f4a2c
13const TRAIL_LIT = 0x7ee787
14const TRAIL = 1.5 // radians of fading trail behind the beam
15const BEAM = 0.06 // radians of solid beam
16
17const BLANK = 0x20
18const BRAILLE = 0x2800
19const BLIP = 0x2836 // ⠶ the four middle dots
20const FLASH = 0x28ff // ⣿ the whole cell, just after the beam passed
21
22// Braille dot bits by [x][y] inside a 2 × 4 cell.
23const DOT = [
24 [0x01, 0x02, 0x04, 0x40],
25 [0x08, 0x10, 0x20, 0x80],
26]
27
28// 4 × 4 ordered dither: a dot of the trail lights when its intensity beats this.
29const BAYER = [
30 [0, 8, 2, 10],
31 [12, 4, 14, 6],
32 [3, 11, 1, 9],
33 [15, 7, 13, 5],
34].map(row => row.map(v => (v + 0.5) / 16))
35
36/** The beam's angle at `now`. */
37export function beamAngle(now: number): number {
38 return ((now % SWEEP_MS) / SWEEP_MS) * TAU
39}
40
41/** How far the beam has gone past `angle`, 0 to 2π. */
42function behind(beam: number, angle: number): number {
43 return (((beam - angle) % TAU) + TAU) % TAU
44}
45
46function mix(from: number, to: number, t: number): number {
47 const c = (shift: number) => {
48 const a = (from >> shift) & 0xff
49 const b = (to >> shift) & 0xff
50
51 return Math.round(a + (b - a) * t) << shift
52 }
53
54 return c(16) | c(8) | c(0)
55}
56
57function dim(color: number, k: number): number {
58 return mix(0, color, k)
59}
60
61/**
62 * The cells of a `columns × rows` radar at `now`, as RasterProps packs them.
63 * A cell is twice as tall as wide, so `columns = 2 × rows` draws a round screen.
64 */
65export function frame(columns: number, rows: number, now: number, blips: readonly Blip[]): string {
66 const words = new Uint32Array(columns * rows * 3)
67 const dotsW = columns * 2
68 const dotsH = rows * 4
69 const cx = dotsW / 2
70 const cy = dotsH / 2
71 const radius = Math.min(dotsW, dotsH) / 2 - 0.5
72 const beam = beamAngle(now)
73
74 for (let row = 0; row < rows; row++) {
75 for (let col = 0; col < columns; col++) {
76 let bits = 0
77 let heat = 0
78 let isGrid = false
79
80 for (let dx = 0; dx < 2; dx++) {
81 for (let dy = 0; dy < 4; dy++) {
82 const px = col * 2 + dx
83 const py = row * 4 + dy
84 const vx = px + 0.5 - cx
85 const vy = py + 0.5 - cy
86 const dist = Math.hypot(vx, vy)
87 if (dist > radius + 0.6) continue
88
89 const onRing = [1, 2 / 3, 1 / 3].some(k => Math.abs(dist - radius * k) < 0.55)
90 const onCross = (Math.abs(vx + 0.25) < 0.5 || Math.abs(vy + 0.25) < 0.5) && (px + py) % 2 === 0
91 if (onRing || onCross) {
92 bits |= DOT[dx]![dy]!
93 isGrid = true
94 }
95 if (dist > radius) continue
96
97 const past = behind(beam, Math.atan2(vx, -vy))
98 if (past < BEAM) {
99 bits |= DOT[dx]![dy]!
100 heat = 1
101 } else if (past < TRAIL) {
102 const glow = (1 - past / TRAIL) ** 1.6
103 if (glow > BAYER[py % 4]![px % 4]!) {
104 bits |= DOT[dx]![dy]!
105 heat = Math.max(heat, glow)
106 }
107 }
108 }
109 }
110
111 const at = (row * columns + col) * 3
112 words[at] = bits === 0 ? BLANK : BRAILLE + bits
113 words[at + 1] = heat > 0 ? mix(TRAIL_DIM, TRAIL_LIT, heat) : isGrid ? GRID : DEFAULT
114 words[at + 2] = DEFAULT
115 }
116 }
117
118 for (const blip of blips) {
119 const px = cx + Math.sin(blip.angle) * blip.range * radius
120 const py = cy - Math.cos(blip.angle) * blip.range * radius
121 const col = Math.min(columns - 1, Math.max(0, Math.floor(px / 2)))
122 const row = Math.min(rows - 1, Math.max(0, Math.floor(py / 4)))
123 const past = behind(beam, blip.angle)
124 const at = (row * columns + col) * 3
125 words[at] = past < 0.35 ? FLASH : BLIP
126 words[at + 1] = dim(blip.color, 0.35 + 0.65 * Math.exp(-past / 1.4))
127 }
128
129 return toBase64(new Uint8Array(words.buffer))
130}
131
132const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
133
134function toBase64(bytes: Uint8Array): string {
135 let out = ''
136 for (let i = 0; i < bytes.length; i += 3) {
137 const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
138 out += ALPHABET.charAt((n >> 18) & 63) + ALPHABET.charAt((n >> 12) & 63)
139 out += i + 1 < bytes.length ? ALPHABET.charAt((n >> 6) & 63) : '='
140 out += i + 2 < bytes.length ? ALPHABET.charAt(n & 63) : '='
141 }
142
143 return out
144}
145hooks/sightings.ts 88 lines1/**
2 * From what a session does to where its blip sits: this session's own
3 * activity read as a state, and every session's state read as a bearing and
4 * a range on the screen.
5 *
6 * @module sightings
7 */
8
9import type { Activity } from '../types'
10import type { Beacon, State } from './beacon'
11import type { Blip } from './scope'
12import type { Texts } from './say'
13
14export const COLOR: Record<State, number> = {
15 busy: 0xffa657,
16 waiting: 0x79c0ff,
17 idle: 0x7f7a6d,
18}
19
20const HOUR = 3_600_000
21
22function hash(text: string): number {
23 let h = 2166136261
24 for (const ch of text) h = Math.imul(h ^ ch.charCodeAt(0), 16777619)
25
26 return (h >>> 0) / 2 ** 32
27}
28
29/** A stable angle per repository, nudged per session so two of one repository part. */
30export function bearing(name: string, sessionId: string): number {
31 return hash(name) * Math.PI * 2 + (hash(sessionId) - 0.5) * 0.5
32}
33
34/** Busy near the center, waiting mid-way, idle drifting to the rim over an hour. */
35export function range(beacon: Pick<Beacon, 'state' | 'since'>, now: number): number {
36 if (beacon.state === 'busy') return 0.3
37 if (beacon.state === 'waiting') return 0.58
38
39 return 0.72 + 0.24 * Math.min(1, Math.max(0, now - beacon.since) / HOUR)
40}
41
42export function blips(beacons: readonly Beacon[], now: number): Blip[] {
43 return beacons.map(b => ({
44 angle: bearing(b.name, b.sessionId),
45 range: range(b, now),
46 color: COLOR[b.state],
47 }))
48}
49
50/** This session's state and its words, from what its own hooks saw. */
51export function stateOf(activity: Activity, say: Texts): { state: State; detail: string } {
52 if (activity.wait === 'answer') return { state: 'waiting', detail: say.awaitsAnswer }
53 if (activity.tool !== null) return { state: 'busy', detail: activity.tool }
54 if (activity.isTurn) return { state: 'busy', detail: say.thinking }
55
56 return { state: 'idle', detail: say.idle }
57}
58
59/** The task a conductor's brief hands over, or undefined for any other message. */
60export function briefOf(text: string, now: number): { id: string; title: string; from: string; since: number } | undefined {
61 const task = /\[conductor\] Task (\S+): ([^\n]*)/.exec(text)
62 if (!task) return undefined
63 const root = /^Work in (.+)\.$/m.exec(text)?.[1] ?? ''
64 const from = /^Handed out by (.+)\.$/m.exec(text)?.[1]
65
66 return {
67 id: task[1] ?? '',
68 title: (task[2] ?? '').trim(),
69 from: from ?? root.split(/[\\/]/).filter(Boolean).pop() ?? '',
70 since: now,
71 }
72}
73
74/** Whether a message the session sends closes the task: delivered, or blocked. */
75export function closes(text: string, id: string): boolean {
76 const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
77
78 return new RegExp(`(?:^|\\n)\\W*(?:DELIVERED|BLOCKED)\\s+${escaped}\\b`, 'i').test(text)
79}
80
81export function elapsed(ms: number): string {
82 const s = Math.max(0, Math.floor(ms / 1000))
83 if (s < 60) return `${s} s`
84 if (s < 3600) return `${Math.floor(s / 60)} min`
85
86 return `${Math.floor(s / 3600)} h`
87}
88hooks/tasks.ts 193 lines1/**
2 * The tasks file: a Markdown checklist, read into tasks and ticked in place.
3 *
4 * - [ ] T-12 Token expiry
5 * Tokens must expire after 15 min; see src/auth.ts.
6 * - [ ] T-19 Load tests (after T-12)
7 * - [x] T-14 Quota screen
8 *
9 * @module tasks
10 */
11
12import type { Assignment, Task } from '../types'
13
14const ITEM = /^(\s*)[-*+]\s+\[( |x|X)\]\s+(.*)$/
15const ID = /^([A-Za-z][A-Za-z0-9]*-\d+)\b[\s:.)-]*/
16const AFTER = /\s*\((?:after|après|apres)\s+([^)]*)\)/i
17/** The mark of a task that is the person's to do, never a session's. */
18const HUMAN = /👤/u
19
20/** Where the tasks file is looked for, in order: the one chosen, else the option's and the usual two. */
21export function candidatesOf(chosen: string, option: string): string[] {
22 const list = chosen.trim() !== '' ? [chosen.trim()] : [option.trim() || 'TASKS.md', 'TASKS.md', 'docs/TASKS.md']
23
24 return [...new Set(list.map(one => one.replace(/\\/g, '/').replace(/^\.\//, '')))]
25}
26
27/** A path from the repository's root made absolute; an absolute one as it is, with `/`. */
28export function resolveIn(root: string, path: string): string {
29 const p = path.trim().replace(/\\/g, '/')
30
31 return /^([A-Za-z]:)?\//.test(p) ? p : `${root.replace(/\/+$/, '')}/${p.replace(/^\.\//, '')}`
32}
33
34/**
35 * Where a task is worked on when each gets its own worktree: a branch
36 * `conductor/<task>` and a folder beside the repository,
37 * `<parent>/<repository>.tasks/<task>`, so nothing lands inside it.
38 */
39export function worktreeOf(root: string, taskId: string): { branch: string; path: string } {
40 const slug = taskId.toLowerCase().replace(/^#/, 'n').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'task'
41 const base = root.replace(/\\/g, '/').replace(/\/+$/, '')
42 const cut = base.lastIndexOf('/')
43 const parent = cut < 0 ? '.' : base.slice(0, cut)
44 const repo = base.slice(cut + 1) || 'repo'
45
46 return { branch: `conductor/${slug}`, path: `${parent}/${repo}.tasks/${slug}` }
47}
48
49/**
50 * What a task's worktree starts from: the branch its first prerequisite was
51 * delivered on, the others' to merge in first; none, the current branch. A
52 * prerequisite delivered with no branch (shared folder) is already there.
53 */
54export function baseOf(task: Task, assignments: readonly Assignment[]): { base?: string; merges: string[] } {
55 const branches = task.after
56 .map(id => assignmentOf(assignments, id))
57 .filter((a): a is Assignment => a?.status === 'delivered' && typeof a.branch === 'string' && a.branch !== '')
58 .map(a => a.branch as string)
59 const [base, ...merges] = [...new Set(branches)]
60
61 return base === undefined ? { merges: [] } : { base, merges }
62}
63
64/** An absolute path as the pane shows it: from the repository's root when under it. */
65export function shownFrom(root: string, path: string): string {
66 const base = `${root.replace(/\/+$/, '')}/`
67
68 return path.toLowerCase().startsWith(base.toLowerCase()) ? path.slice(base.length) : path
69}
70
71export function parseTasks(text: string): Task[] {
72 const lines = text.split(/\r?\n/)
73 const tasks: Task[] = []
74 let open: { task: Task; indent: number; details: string[] } | undefined
75
76 const close = () => {
77 if (open) open.task.details = open.details.join('\n').trim()
78 open = undefined
79 }
80
81 lines.forEach((line, at) => {
82 const item = ITEM.exec(line)
83 if (item) {
84 close()
85 let rest = item[3] ?? ''
86 const after: string[] = []
87 rest = rest.replace(AFTER, (_, ids: string) => {
88 after.push(...ids.split(/[\s,;]+/).filter(Boolean))
89
90 return ''
91 })
92 const named = ID.exec(rest)
93 const id = named?.[1] ?? `#${tasks.length + 1}`
94 const title = (named ? rest.slice(named[0].length) : rest).trim()
95 const isHuman = HUMAN.test(title)
96 const task: Task = { id, title, details: '', after, isDone: item[2] !== ' ', isHuman, line: at }
97 tasks.push(task)
98 open = { task, indent: (item[1] ?? '').length, details: [] }
99
100 return
101 }
102 if (!open) return
103 const indent = /^\s*/.exec(line)?.[0].length ?? 0
104 if (line.trim() === '' || indent > open.indent) open.details.push(line.trim())
105 else close()
106 })
107 close()
108
109 return tasks
110}
111
112/** The file with one task's box checked, or undefined when the task is not there. */
113export function tickTask(text: string, id: string): string | undefined {
114 const task = parseTasks(text).find(one => one.id === id)
115 if (!task) return undefined
116 const eol = text.includes('\r\n') ? '\r\n' : '\n'
117 const lines = text.split(/\r?\n/)
118 lines[task.line] = (lines[task.line] ?? '').replace(/\[ \]/, '[x]')
119
120 return lines.join(eol)
121}
122
123/** The assignment a task has now: the newest one, delivered or not. */
124export function assignmentOf(assignments: readonly Assignment[], id: string): Assignment | undefined {
125 return assignments.filter(one => one.taskId === id).pop()
126}
127
128/**
129 * Whether a task can be given out: open, the person's to do (👤) never, never
130 * given (a blocked one waits for the person to put it back in the queue), its
131 * prerequisites done.
132 */
133export function isReady(task: Task, tasks: readonly Task[], assignments: readonly Assignment[]): boolean {
134 if (task.isDone || task.isHuman || assignmentOf(assignments, task.id)) return false
135
136 return blockersOf(task, tasks).length === 0
137}
138
139/** The prerequisites of a task still open; one the file does not hold blocks nothing. */
140export function blockersOf(task: Task, tasks: readonly Task[]): string[] {
141 return task.after.filter(id => tasks.some(one => one.id === id && !one.isDone))
142}
143
144/** How many assignments are kept for a repository, across sessions. */
145export const KEPT = 200
146
147/** The assignments a store held, the malformed left out; [] for anything else. */
148export function assignmentsOf(raw: unknown): Assignment[] {
149 if (!Array.isArray(raw)) return []
150 const STATUSES = ['working', 'question', 'blocked', 'delivered']
151
152 return raw
153 .filter(
154 (one): one is Assignment =>
155 typeof one === 'object' &&
156 one !== null &&
157 ['taskId', 'sessionId', 'name', 'note'].every(k => typeof (one as Record<string, unknown>)[k] === 'string') &&
158 typeof (one as Record<string, unknown>).since === 'number' &&
159 STATUSES.includes(String((one as Record<string, unknown>).status)),
160 )
161 .slice(-KEPT)
162}
163
164/** Whether a session holds a task now: working on it, or waiting on an answer. */
165export function isBusy(sessionId: string, assignments: readonly Assignment[]): boolean {
166 const latest = new Map<string, Assignment>()
167 for (const one of assignments) latest.set(one.taskId, one)
168
169 return [...latest.values()].some(one => one.sessionId === sessionId && (one.status === 'working' || one.status === 'question'))
170}
171
172/**
173 * The next moves: each free session, in order, gets the next ready task, in
174 * the file's order; a session is free when it takes tasks (`/radar
175 * available`), holds none and is idle.
176 */
177export function plan(
178 tasks: readonly Task[],
179 assignments: readonly Assignment[],
180 sessions: readonly { sessionId: string; name: string; state: string; isAvailable?: boolean }[],
181): { task: Task; sessionId: string; name: string }[] {
182 const ready = tasks.filter(task => isReady(task, tasks, assignments))
183 const free = sessions.filter(s => s.isAvailable === true && s.state === 'idle' && !isBusy(s.sessionId, assignments))
184 const moves: { task: Task; sessionId: string; name: string }[] = []
185 for (const [i, s] of free.entries()) {
186 const task = ready[i]
187 if (!task) break
188 moves.push({ task, sessionId: s.sessionId, name: s.name })
189 }
190
191 return moves
192}
193types/index.d.ts 97 lines1/** What a session is doing, as its radar says it. */
2export type State = 'busy' | 'waiting' | 'idle'
3
4export type Beacon = {
5 v: 1
6 sessionId: string
7 /** The repository's folder name, or the working directory's. */
8 name: string
9 branch: string
10 state: State
11 /** The tool running, or what the session waits for. */
12 detail: string
13 /** When the state began, ms since the epoch. */
14 since: number
15 /** When the beacon was last written. */
16 beatAt: number
17 /** Set by the session's last write, as it ends. */
18 endedAt?: number
19 /** Set while the session takes tasks from a conductor (`/radar available`). */
20 isAvailable?: boolean
21}
22
23/** What this session is doing, as its own hooks saw it. */
24export type Activity = {
25 /** The tool running now, or null. */
26 tool: string | null
27 /** True from `turn.start` to `turn.complete`. */
28 isTurn: boolean
29 /** Set while the session waits on the person to answer its question. */
30 wait: 'answer' | null
31 /** When the current state began, ms since the epoch. */
32 since: number
33}
34
35/** A task a conductor handed this session, as its brief said it. */
36export type Received = {
37 id: string
38 title: string
39 /** The folder name of the repository the conductor runs in. */
40 from: string
41 since: number
42}
43
44/** One task of the tasks file: a checklist line and the lines indented under it. */
45export type Task = {
46 /** `T-12` as written, or `#3` (its rank) for a line that names none. */
47 id: string
48 title: string
49 /** The indented lines under it, sent with the task. */
50 details: string
51 /** The ids it waits for: `(after T-12, T-13)`. */
52 after: string[]
53 /** Checked: `- [x]`. */
54 isDone: boolean
55 /** Marked 👤: the person's to do, never handed to a session. */
56 isHuman: boolean
57 /** Its line in the file, 0-based. */
58 line: number
59}
60
61/** Where a task given to a session stands, as its reports say. */
62export type Status = 'working' | 'question' | 'blocked' | 'delivered'
63
64export type Assignment = {
65 taskId: string
66 /** The session it went to, and its name on the radar. */
67 sessionId: string
68 name: string
69 status: Status
70 /** The last report's text: the question, why it is blocked, what was delivered. */
71 note: string
72 /** When the status began, ms since the epoch. */
73 since: number
74 /** The branch of the task's own worktree, when it got one. */
75 branch?: string
76}
77
78declare module 'claude-code' {
79 interface PluginState {
80 radar: {
81 activity: Activity
82 peers: Beacon[]
83 isAvailable: boolean
84 task: Received | null
85 // The conductor: the tasks of the tasks file, who holds which, where the
86 // file is, and whether ready tasks go out by themselves.
87 tasks: Task[]
88 assignments: Assignment[]
89 /** The tasks file's absolute path, or empty while none is found. */
90 file: string
91 /** The tasks file the person named in the pane; empty, the usual places. */
92 chosen: string
93 isAuto: boolean
94 }
95 }
96}
97