SLOPSHOPPER

radar

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…

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-07devohmycode/ccmods/radar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · radar
│ ┃ Radar ✕ › fix the failing auth test and add an audit log call │ ┃ ⌖ Control tower 1 session │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ ⢀⣠⠤⠖⢞⢝⢝⢝⢽⢵⣶⡤⣄⡀ │ ⎿ Read 6 lines │ ┃ │ ⢀⠔⠍⠁⠅⠕⢅⢕⢕⢝⢽⢿⡿⠁ ⠉⠢⡀ │ ⏺ Update(src/auth.ts) │ ┃ │ ⢀⡔⠁⠁ ⠁⡥⠕⠟⠝⢝⢝⢽⠿⠣⢄ ⠈⢢⡀ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ ⡞ ⡠⠋ ⠁⠅⠕⢕⢽⣽⠃ ⠑⢄⠶ ⢳ │ ⏺ Bash(bun test) │ ┃ │ ⡸ ⣰⠁ ⢁⠔⠛⢝⢽⠛⠢⡀ ⠈⣆ ⢇ │ ⎿ 3 pass, 1 fail │ ┃ │ ⣇⢀⢀⢀⣇⢀⢀⢀⣏⢀⢁⢑⢍⢀⢀⢹⢀⢀⢀⢸⢀⢀⢀⢸ │ │ ┃ │ ⡇ ⡇ ⣇ ⢐ ⣸ ⢸ ⢸ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ⢱ ⠹⡀ ⠈⠢⣄⣐⣀⣠⠔⠁ ⢀⠏ ⡎ │ │ ┃ │ ⢧ ⠑⢄ ⢐ ⡠⠊ ⡼ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ⠈⠣⡀ ⠑⠢⢤⣀⣐⣀⣀⡤⠔⠊ ⢀⠜⠁ │ │ ┃ │ ⠈⠢⣀ │ › /radar │ ┃ │ │ ⎿ radar: Control tower opened. │ ┃ │ │ │ ┃ │ │ │ ┃ │ ● app (you) feat/auth-refresh │ │ ┃ │ · idle · 0 s │ │ ┃ │ No other session yet: one appears once │ │ ┃ │ radar runs in it, under this config │ │ ┃ │ folder or one extraConfigDirs names. │ │ ┃ │ │ │ ┃ │ [ ⛑ Take tasks ] │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Radar
⌖ Control tower 1 session ╭──────────────────────────────────────────────────────────╮ │ ⢀⣠⠤⠖⢞⢝⢝⢝⢽⢵⣶⡤⣄⡀ │ │ ⢀⠔⠍⠁⠅⠕⢅⢕⢕⢝⢽⢿⡿⠁ ⠉⠢⡀ │ │ ⢀⡔⠁⠁ ⠁⡥⠕⠟⠝⢝⢝⢽⠿⠣⢄ ⠈⢢⡀ │ │ ⡞ ⡠⠋ ⠁⠅⠕⢕⢽⣽⠃ ⠑⢄⠶ ⢳ │ │ ⡸ ⣰⠁ ⢁⠔⠛⢝⢽⠛⠢⡀ ⠈⣆ ⢇ │ │ ⣇⢀⢀⢀⣇⢀⢀⢀⣏⢀⢁⢑⢍⢀⢀⢹⢀⢀⢀⢸⢀⢀⢀⢸ │ │ ⡇ ⡇ ⣇ ⢐ ⣸ ⢸ ⢸ │ │ ⢱ ⠹⡀ ⠈⠢⣄⣐⣀⣠⠔⠁ ⢀⠏ ⡎ │ │ ⢧ ⠑⢄ ⢐ ⡠⠊ ⡼ │ │ ⠈⠣⡀ ⠑⠢⢤⣀⣐⣀⣀⡤⠔⠊ ⢀⠜⠁ │ │ ⠈⠢⣀ │ │ │ │ │ │ │ │ ● app (you) feat/auth-refresh │ │ · idle · 0 s │ │ No other session yet: one appears once radar runs in it, │ │ under this config folder or one extraConfigDirs names. │ │ │ │ [ ⛑ Take tasks ] │ ╰──────────────────────────────────────────────────────────╯ ⛑ Conductor 0 sessions · manual ╭──────────────────────────────────────────────────────────╮ │ Tasks file: TASKS.md · docs/TASKS.md │ │ No tasks file (looked for TASKS.md, docs/TASKS.md). Name │ │ one above, or write one: one checklist line per task, "- │ │ [ ] T-1 Title (after T-0)", indented lines for its │ │ brief. │ │ [ ✎ Have Claude write docs/TASKS.md ] │ ╰──────────────────────────────────────────────────────────╯
README

radar — your sessions on a radar

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:

  • bearing: fixed per repository, so a session keeps its place; two sessions of one repository sit side by side;
  • range: busy (a turn or a tool running) near the center, waiting on you (a question asked with AskUserQuestion) mid-way, idle at the rim, drifting outwards over an hour;
  • color: orange busy, blue waiting, grey idle.

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.

Usage

CommandEffect
/radaropens the pane
/radar availablethis session takes tasks from a conductor, or stops; the pane's ⛑ Take tasks button does the same
/radar conductoropens 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.

The conductor

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:

  • Tasks file: the file to read, from the repository's root or absolute, kept for this repository across sessions. Empty, the conductor looks for the tasksFile option's file, then TASKS.md, then docs/TASKS.md.
  • ✎ Have Claude write docs/TASKS.md, shown only while no tasks file is found: asks this session's Claude to write one from this conversation and the repository, in the format below; the pane reads it once it is there.
  • ⇢ T-16 → docs gives the next ready task to a free session; one button per free session.
  • ▶ Auto gives the ready tasks out by itself, as sessions free up; ⏸ Pause stops it. Off until you turn it on.
  • ↩ Answer under a question sends your answer, and the session carries on. While a question waits, the band above the prompt says so too.
  • ↺ Requeue puts a blocked task back in the queue.

The tasks file

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.

The brief and the reports

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.

A worktree per task

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.

Settings

OptionValuesDefault
languageen, fren
isolationworktree, sharedworktree
tasksFilethe conductor's checklist, from the repository's root or absolute, looked for firstTASKS.md
extraConfigDirsconfiguration 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.

How it works

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.

Out of scope, for now

  • Writing to a session from the pane, and its answer as a toast.
  • Telling a session waiting on a permission dialog from a busy one: the verdict a hook sees comes before the permission mode settles it, so auto mode would read as waiting.
  • Merging a delivered branch from the pane.
  • Remote sessions started by an agent with isolation: 'remote': a cloud session cannot message another session back yet.
  • A delivery is taken at its word: nothing checks the commit or the PR it names.
Source 9 files
hooks/register.tsx 645 lines
1/**
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}
645
hooks/beacon.ts 117 lines
1/**
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}
117
hooks/conductor.tsx 277 lines
1/**
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}
277
hooks/protocol.ts 86 lines
1/**
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}
86
hooks/say.ts 133 lines
1/**
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}
133
hooks/scope.ts 145 lines
1// 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}
145
hooks/sightings.ts 88 lines
1/**
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}
88
hooks/tasks.ts 193 lines
1/**
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}
193
types/index.d.ts 97 lines
1/** 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