SLOPSHOPPER

x-mod-stash

dima's command center above the prompt, one folded row: asks (every live session's open ⏳ asks, copy all per thread), afk (one switch every session obeys)…

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview Β· a replayed session in a sandbox
claude Β· ~/work/app Β· x-mod-stash
β”‚ ┃ fleet board βœ• β€Ί fix the failing auth test and add an audit log call β”‚ ┃ no live sessions β”‚ ⏺ 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 β”‚ β”‚ β€Ί /board β”‚ ⎿ x-mod-stash: fleet board opened β”‚ β”‚ no open asks πŸ”₯ ⏰ πŸ’¨ [ 🚦 ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── β€Ί ? for shortcuts

Draws

Band
no open asks πŸ”₯ ⏰ πŸ’¨ [ 🚦 ] ⟨Claude Code's own drawing⟩
Pane Β· fleet board
no live sessions
README

🎞️ the frame: a place where most of the machine's setup lives as data.

<a href="https://github.com/dvakatsiienko/frame/actions/workflows/ci.yml"><img src="https://raw.githubusercontent.com/dvakatsiienko/frame/badges/ci.svg" alt="ci"></a> <img src="assets/badges/tests.svg" alt="tests"> <img src="assets/badges/renovate.svg" alt="renovate"> <img src="assets/badges/node.svg" alt="node"> <img src="assets/badges/pnpm.svg" alt="pnpm"> <img src="assets/badges/skills.svg" alt="skills"> <img src="assets/badges/mirrored.svg" alt="mirrored">

<img src="assets/banner.svg" align="left" width="100%" alt="frame β€” the inventory of one mac"> <img src="assets/mac.svg" align="right" width="36%" alt="an 80s mac showing ~ frame">

🧭 toc

  • fleet β€” skills, memory, sline, hotkeys
  • chords β€” the keyboard map, live
  • mirror β€” home/ is ~
  • machine β€” brew, defaults, launchd
  • link it β€” a fresh mac install

πŸ›Έ fleet

home/.claude/ is the claude code setup every session reads: memories, rules, skills (plugin-x), hooks, output styles, and sline, the statusline. cclio/ is the coordinator's home, the session that plans and routes work to background coders. x/ is x, the agent-first cli in go + charm: one verb registry, json for agents and boards for humans (x lane commits and pushes from a sandboxed worktree). hotkeys/ maps every keyboard chord on the machine and serves chords, the map's app. x-speak (a schedule/ daemon) reads the selected text aloud on F4 (with nothing selected it pauses / resumes, as ⇧F4 always does) and stops on F5, rewriting ids, versions, paths and code into something a voice can say; speak/ is its voice admin (pnpm speak:admin).

<img src="home/.claude/sline/showcase/sline.svg" width="100%" alt="sline, the statusline, as a session climbs from fresh to heavy">

🎹 chords

chords is a keyboard map app: every key binding on every modifier layer, how often each one fires, and which keys are still free. powered by launchd daemon that counts key presses.

<img src="hotkeys/chords/showcase.png" width="100%" alt="chords: the hyper layer on a NuPhy Air75, each bound key with its app and press count">

πŸͺž mirror

the mirror links dotfiles and configs. a path under home/ is the same path under ~. the link map is derived by walking the tree, so adding a file to home/ auto-tracks it.

pnpm frame:link                        # status
pnpm frame:link apply                  # link everything not linked yet
pnpm frame:link register ~/.foo        # move a file into the mirror and link it back
pnpm frame:link untrack ~/.gitconfig   # hand a file back to ~

<img src="assets/frame-link.gif" width="720" alt="pnpm frame:link, ending on everything mirrored">

πŸ’» machine

the mac's setup is kept as data, because data does not rot and scripts do: the Brewfile, the macos defaults, the duti file bindings, and the launchd jobs under schedule/.

pnpm macos:setup   # brew bundle, macos defaults, duti, vim-plug

πŸ”— link it

on a fresh machine, install the command line tools first β€” the clone itself needs git, and macos ships only a shim that opens the install dialog. then clone to ~/frame and run the seed, or hand it to an agent (Β«seed this mac from frameΒ»):

xcode-select --install      # stop 0: the dialog, ~2 min
git clone https://github.com/dvakatsiienko/frame ~/frame && cd ~/frame
script/seed.sh                     # command line tools β†’ brew β†’ fnm, pnpm, node β†’ pnpm i β†’ macos:setup β†’ sline β†’ claude cli β†’ frame:link apply
script/seed.sh --claude            # the same, with ~/.claude linked
script/seed.sh --without-appstore  # skip the app store apps and their sign-in
script/seed.sh --dry-run           # what it would do, nothing changed

one status line per step, safe to re-run. it stops with needs your hands: … (exit 2) where only a human can act β€” the command line tools dialog, the homebrew password, files in the way of a link, the app store and 1password sign-ins β€” and the next run picks up from there. when it ends, open a new terminal: a shell opened before the seed never sources the zsh stubs it wrote.

Source 3 files
hooks/register.tsx 1394 lines
1/* @jsx h */
2import type { EngineInterface, Register, RenderElement } from 'claude-code';
3
4import type {
5    StashCap,
6    StashDigest,
7    StashEntry,
8    StashMember,
9    StashView,
10} from '../types/stash.d.ts';
11import {
12    type Door,
13    boldFleetWords,
14    bulletDots,
15    doorOf,
16    isFleetName,
17    nestedAsks,
18    parseAfter,
19    parseAsks,
20    parseWait,
21    ticketOf,
22} from './parse.ts';
23
24// x-mod-stash: dima's command center above the prompt, one folded row; FTR.md lists every feature.
25// asks: every live session's open ⏳ asks, mirrored from each last reply into $.store (one key per session).
26// afk: one switch every session polls; while it is on, every prompt carries an away note, and a flip reaches a running turn.
27// keep-hot: while on and idle, one ping 50 min after the last turn ended keeps the prompt cache warm; the switch lives in $.store, so a reload keeps it
28// The reply stays the source of truth for asks; this band only shows and copies them.
29
30// label: the repo; name: the session's registry name, when it has one
31type Entry = StashEntry;
32// a session's last reply: when it ended, its πŸ”­ wait; `ended` once the session itself exited
33type Reply = { at: number; name?: string; wait?: string; ended?: number };
34
35const PREFIX = 'asks:';
36const AFK_KEY = 'afk';
37const AWAY_NOTE =
38    'dima is afk: nothing waits on him. take reversible steps and log them, park every ask for his return, send no ⏳ block and no ping.';
39const BACK_NOTE =
40    'dima is back from afk: asks and the ⏳ block reach him again.';
41const POLL_MS = 4000;
42// the fleet board: cc's registry gives each session's state; each session's stash adds its πŸ”­ wait and context fill
43const BOARD = 'fleet-board';
44const REPLY = 'reply:';
45const CONTEXT = 'context:';
46// the model and effort of a session's main loop, as its last model request named them
47const MODEL = 'model:';
48// the board's colour MVP: one fleet-wide switch, `/board colour`, off by default (FRM-329)
49const COLOUR = 'board-colour';
50// the registry names a short-lived headless run `t-` + hex (`t-70`); the board leaves those out (dima, 2026-10-05)
51const HEADLESS = /^t-[0-9a-f]+$/;
52// a session younger than this, counted from the registry's `startedAt`, is a probe: it gets no row until it outlives it
53const SHORT_MS = 60_000;
54const STALE_MS = 24 * 60 * 60 * 1000;
55// dima at the prompt, typing into a background job, on his phone or the web
56const DIMA_ORIGINS = ['composer', 'sdk', 'bridge'] as const;
57const HOT_MS = 50 * 60 * 1000;
58const HOT = 'hot:';
59// a reload keeps `$.state` and resets the module: what must outlive a mod save lives here (FRM-320)
60const OPEN = { key: 'open', plugin: 'x-mod-stash' } as const;
61// what the band and the board draw: renders read it, and a write redraws them, no invalidate
62const VIEW = { key: 'view', plugin: 'x-mod-stash' } as const;
63// what only the board pane draws, its clock included: the band never reads it, so a tick never redraws the band
64const BOARD_VIEW = { key: 'board', plugin: 'x-mod-stash' } as const;
65// the running turn, so a mid-turn reload still knows it is busy
66const TURN = { key: 'turn', plugin: 'x-mod-stash' } as const;
67// a πŸ“„ line's HH:MM β€” the same shape reply-check.py reads
68const STAMP = /(πŸ“„[^\n]*?\b)(\d{1,2}:\d{2})\b/g;
69const PING =
70    'x-mod-stash keep-hot ping: answer with one character, nothing else.';
71// the waker: one global switch, off by default; when on, a session stopped on the 5h cap gets one resume at the reset
72const WAKER_KEY = 'waker';
73const CAP = { key: 'cap', plugin: 'x-mod-stash' } as const;
74// the 5h cap ends the turn as an API error with this text; the weekly cap and spent credits read otherwise
75// (8 rows in the transcripts, 2026-10-08)
76const CAPPED = /hit your session limit/i;
77const RESUME =
78    'x-mod-stash waker: the 5h window reset. pick up where you stopped.';
79// a few seconds past the reset, so the window has turned before the resume goes out
80const WAKE_SLACK_MS = 5000;
81const ACCENT = '#d97757';
82
83let selfId: string | undefined;
84let label = 'session';
85let entries: Record<string, Entry> = {};
86// the asks list's fold: only dima's click changes it β€” new asks, cleared asks and a new turn never do
87let open = true;
88let isUserTurn = false;
89let pinged = false;
90let isPolling = false;
91let afk = false;
92let isWaker = false;
93// the 5h reset as the last session.measure reported it, and this session's cap state
94let fiveHourResetsAt: number | undefined;
95let cap: StashCap | undefined;
96let capGen = 0;
97let turnAfk: boolean | undefined;
98let proc: Proc | undefined;
99// since: the turn end the next ping counts from; only dima's click turns it off, a 5h reset never does
100let hot: { since: number } | undefined;
101let isBusy = false;
102// the model label last written for this session, so a step writes the store only when it changes
103let modelSeen: string | undefined;
104// a turn start or a flip bumps it, so a ping armed before either never fires
105let hotGen = 0;
106
107// what the fleet did while dima was afk, shown in the band where he turned πŸ’¨ off until his next prompt
108type Digest = StashDigest;
109let digest: Digest | undefined;
110
111// a thread's asks show while it has any and they are under a day old
112const isLive = (v: Entry | undefined, now: number): v is Entry =>
113    !!v && Array.isArray(v.asks) && v.asks.length > 0 && now - v.at < STALE_MS;
114
115const errorText = (err: unknown) =>
116    err instanceof Error ? err.message : String(err);
117
118const basename = (path: string) =>
119    path.split('/').filter(Boolean).pop() ?? path;
120
121type Proc = { pid: number; start: string };
122
123const short = (sid: string) => sid.slice(0, 8);
124
125// the name ListAgents shows lives in the session registry, keyed by the claude process id
126async function sessionName($: EngineInterface) {
127    if (!proc) return undefined;
128    const home = await $.env.get('HOME');
129    const raw = await $.fs
130        .read(`${home}/.claude/sessions/${proc.pid}.json`)
131        .catch(() => undefined);
132    if (!raw) return undefined;
133    try {
134        const { name } = JSON.parse(raw) as { name?: unknown };
135        return typeof name === 'string' && name ? name : undefined;
136    } catch {
137        return undefined;
138    }
139}
140
141async function procOf($: EngineInterface): Promise<Proc | undefined> {
142    const r = await $.process.run([
143        'sh',
144        '-c',
145        'echo $PPID; ps -o lstart= -p $PPID',
146    ]);
147    const [pid, start] = r.stdout.split('\n').map((s) => s.trim());
148    return r.exitCode === 0 && pid && start
149        ? { pid: Number(pid), start }
150        : undefined;
151}
152
153// a Β«yes, after XΒ» answer to an open ask lands one line in the queue cclio's boot reads and empties; cclio/pocket.md has one writer
154async function queueAfter($: EngineInterface, prompt: string) {
155    const entry = (await $.store.get(PREFIX + (await $.session.id()))) as
156        | Entry
157        | undefined;
158    const found = parseAfter(prompt, entry?.asks ?? []);
159    if (!found.length) return;
160    const path = `${await $.env.get('HOME')}/.claude/shelf/stash/pocket-queue.md`;
161    const d = new Date(await $.clock.now());
162    const two = (n: number) => String(n).padStart(2, '0');
163    const at = `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`;
164    const was = await $.fs.read(path).catch(() => '');
165    const lines = found.map((f) => `- ${at} Β· after ${f.after} Β· ${f.ask}\n`);
166    await $.fs.write(path, `${was}${lines.join('')}`);
167}
168
169// everything this mod keeps for one conversation
170async function forget($: EngineInterface, sid: string) {
171    await $.store.delete(PREFIX + sid);
172    await $.store.delete(HOT + sid);
173    await $.store.delete(REPLY + sid);
174    await $.store.delete(CONTEXT + sid);
175    await $.store.delete(MODEL + sid);
176}
177
178// a session that exited keeps only its last reply, marked ended, so an away digest still counts it as done
179async function retire($: EngineInterface, sid: string) {
180    const reply = (await $.store.get(REPLY + sid)) as Reply | undefined;
181    await forget($, sid);
182    if (reply)
183        await $.store.set(REPLY + sid, {
184            ...reply,
185            ended: await $.clock.now(),
186        } satisfies Reply);
187}
188
189// an ended session's reply is dropped a day after it ended
190async function pruneEnded($: EngineInterface) {
191    const now = await $.clock.now();
192    for (const key of await $.store.keys()) {
193        if (!key.startsWith(REPLY)) continue;
194        const v = (await $.store.get(key)) as Reply | undefined;
195        if (v?.ended !== undefined && now - v.ended >= STALE_MS)
196            await $.store.delete(key);
197    }
198}
199
200// one `words:<yyyy-mm-dd>:<session>` key a day, the fleet words a reply had bolded for it, so a halt reads the hits;
201// a key past WORDS_DAYS is dropped
202const WORDS = 'words:';
203const WORDS_DAYS = 30;
204async function countWords($: EngineInterface, hits: Record<string, number>) {
205    const at = await $.clock.now();
206    const day = (ms: number) => new Date(ms).toLocaleDateString('sv');
207    const key = `${WORDS}${day(at)}:${await $.session.id()}`;
208    const was = ((await $.store.get(key)) ?? {}) as Record<string, number>;
209    const next = { ...was };
210    for (const [word, n] of Object.entries(hits))
211        next[word] = (next[word] ?? 0) + n;
212    await $.store.set(key, next);
213    const oldest = `${WORDS}${day(at - WORDS_DAYS * 86_400_000)}`;
214    for (const old of await $.store.keys())
215        if (old.startsWith(WORDS) && old < oldest) await $.store.delete(old);
216}
217
218async function load($: EngineInterface) {
219    const now = await $.clock.now();
220    const flag = (await $.store.get(AFK_KEY)) as { on?: boolean } | undefined;
221    afk = flag?.on === true;
222    const waker = (await $.store.get(WAKER_KEY)) as
223        | { on?: boolean }
224        | undefined;
225    isWaker = waker?.on === true;
226    const next: Record<string, Entry> = {};
227    for (const key of await $.store.keys()) {
228        if (!key.startsWith(PREFIX)) continue;
229        const v = (await $.store.get(key)) as Entry | undefined;
230        if (isLive(v, now)) next[key.slice(PREFIX.length)] = v;
231    }
232    // after a /clear the process goes on under a new id, and no session.start fires
233    selfId = await $.session.id().catch(() => selfId);
234    // a session that died without its exit hook leaves its asks in the store: show only sessions still running
235    if (Object.keys(next).some((sid) => sid !== selfId)) {
236        const alive = await registry($)
237            .then(
238                (rows) =>
239                    new Set(
240                        rows.filter((r) => r.isAlive).map((r) => r.base.sid),
241                    ),
242            )
243            .catch(() => undefined);
244        if (alive)
245            for (const sid of Object.keys(next))
246                if (sid !== selfId && !alive.has(sid)) delete next[sid];
247    }
248    entries = next;
249    await publish($);
250}
251
252// the band's view the module's values make
253function viewOf(isBoardOpen: boolean): StashView {
254    return {
255        afk,
256        digest,
257        entries,
258        isBoardOpen,
259        isHot: Boolean(hot),
260        isWaker,
261        selfId,
262    };
263}
264
265// a capped session's resume, armed for the reset it waits on; a turn start or a new cap bumps capGen, so an old timer
266// never fires
267async function armWake($: EngineInterface) {
268    const mine = ++capGen;
269    const at = cap?.resetsAt;
270    if (at === undefined || cap?.sentFor === at) return;
271    const wait = at + WAKE_SLACK_MS - (await $.clock.now());
272    $.clock.after(Math.max(0, wait), async () => {
273        if (mine !== capGen || isBusy || !cap) return;
274        const waker = (await $.store.get(WAKER_KEY)) as
275            | { on?: boolean }
276            | undefined;
277        if (waker?.on !== true) return;
278        cap = { ...cap, sentFor: at };
279        await $.state.set(CAP, cap).catch(() => undefined);
280        $.ui.log('x-mod-stash waker: resumed the session after the 5h reset');
281        await $.prompt.submit({ text: RESUME });
282    });
283}
284
285let published = '';
286let boardPublished = '';
287// writes the view to $.state when it changed; every site that reads it while drawing redraws
288async function publish($: EngineInterface) {
289    const isBoardOpen = await isOpen($);
290    if (isBoardOpen) {
291        // each board write redraws the pane, and the desktop then rebuilds x-mod-breather's svg in the band (a blink and a
292        // restarted breath, FRM-354): it writes only when what the board shows changes β€” a row's facts, or a span such as
293        // `busy 3m` turning its minute β€” never for the clock alone
294        const now = await $.clock.now();
295        const board = {
296            at: now,
297            isColour: (await $.store.get(COLOUR).catch(() => false)) === true,
298            members: await members($).catch(() => undefined),
299        };
300        const boardText = JSON.stringify({
301            ...board,
302            at: board.members?.map((m) =>
303                m.statusSince === undefined ? null : span(now - m.statusSince),
304            ),
305        });
306        if (boardText !== boardPublished) {
307            boardPublished = boardText;
308            await $.state.set(BOARD_VIEW, board).catch(() => undefined);
309        }
310    }
311    const view = viewOf(isBoardOpen);
312    const text = JSON.stringify(view);
313    if (text === published) return;
314    published = text;
315    await $.state
316        .set(VIEW, view)
317        .catch((err) =>
318            $.ui.log(`x-mod-stash: the view was not kept: ${errorText(err)}`),
319        );
320}
321
322async function keepTurn($: EngineInterface) {
323    await $.state
324        .set(TURN, { afk: turnAfk, isBusy, isPinged: pinged, isUserTurn })
325        .catch(() => undefined);
326}
327
328// this session's id as session.start read it, else asked now; undefined when the engine cannot say
329async function currentId($: EngineInterface) {
330    return selfId ?? (await $.session.id().catch(() => undefined));
331}
332
333async function saveHot($: EngineInterface) {
334    const sid = await currentId($);
335    if (!sid) return;
336    if (hot) await $.store.set(HOT + sid, hot);
337    else await $.store.delete(HOT + sid);
338}
339
340async function armHot($: EngineInterface) {
341    const mine = ++hotGen;
342    if (!hot || isBusy) return;
343    const wait = hot.since + HOT_MS - (await $.clock.now());
344    $.clock.after(Math.max(0, wait), async () => {
345        if (!hot || isBusy || mine !== hotGen) return;
346        $.ui.log('x-mod-stash keep-hot: pinged the idle session');
347        await $.prompt.submit({ text: PING });
348    });
349}
350
351type Member = StashMember;
352
353// `claude-opus-5-5` + `medium` reads Β«opus 5.5 Β· mediumΒ»; a dated or `[1m]` id drops its tail, an id off the pattern shows as written
354function modelLabel(model: string, effort?: string | number) {
355    const m = model.match(
356        /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?=$|-\d{8}|\[)/,
357    );
358    const name = m ? `${m[1]} ${m[2]}${m[3] ? `.${m[3]}` : ''}` : model;
359    return effort === undefined ? name : `${name} Β· ${effort}`;
360}
361
362// the colour look, borrowed from the reference on FRM-329: calm by default, each member's dot its own hue, and a
363// colour only where something is live β€” theme keys follow light and dark; orange stays the asks' alone
364// six mid-tones at 3.6:1 or more on white and 4.2:1 on a dark pane; no coral, which would read as the asks' orange
365const HUES = [
366    '#4f83e0',
367    '#2f8f8a',
368    '#a87f12',
369    '#47915a',
370    '#9a74c9',
371    '#c4689f',
372] as const;
373// each member's hue: the one its name hashes to, so it holds across reloads, or the next free one when an earlier
374// row took it β€” up to six members never share a colour
375export function huesOf(names: string[]) {
376    const used = new Set<number>();
377    return names.map((name) => {
378        let h = 0;
379        for (const c of name) h = (h * 31 + (c.codePointAt(0) ?? 0)) >>> 0;
380        let at = h % HUES.length;
381        for (let n = 0; n < HUES.length && used.has(at); n++)
382            at = (at + 1) % HUES.length;
383        used.add(at);
384        return HUES[at];
385    });
386}
387// cc's registry status: the word the board shows, and its tint with colour on. `shell` is a long command
388// inside a turn, so it reads busy; a status cc adds later reads as itself, amber
389const STATUS = {
390    blocked: { tint: 'error', word: 'blocked' },
391    busy: { tint: 'success', word: 'busy' },
392    idle: { tint: undefined, word: 'idle' },
393    needs_input: { tint: 'warning', word: 'needs_input' },
394    shell: { tint: 'success', word: 'busy' },
395    waiting: { tint: 'warning', word: 'waiting' },
396} as const satisfies Record<string, { tint: string | undefined; word: string }>;
397const isStatus = (status: string): status is keyof typeof STATUS =>
398    Object.hasOwn(STATUS, status);
399const statusOf = (status: string | undefined) =>
400    status !== undefined && isStatus(status)
401        ? STATUS[status]
402        : { tint: 'warning', word: status ?? '?' };
403// sline's bar ramp, yellow, orange, red, tuned to 3.2:1 or more on a light pane and 4.2:1 on a dark one
404const RAMP = ['#b98500', '#d9661a', '#e5484d'] as const;
405// a calm context is dim; a filling one climbs the ramp
406const contextTint = (percent: number) =>
407    percent >= 80
408        ? RAMP[2]
409        : percent >= 65
410          ? RAMP[1]
411          : percent >= 50
412            ? RAMP[0]
413            : undefined;
414// the prompt cache lives an hour: an idle session stays gray, then climbs the ramp as it cools, red from half the
415// cache's life; cold past all of it
416const COOLING_MS = [15 * 60_000, 22 * 60_000, 30 * 60_000] as const;
417const COLD_MS = 60 * 60_000;
418const idleTint = (ms: number) =>
419    ms >= COOLING_MS[2]
420        ? RAMP[2]
421        : ms >= COOLING_MS[1]
422          ? RAMP[1]
423          : ms >= COOLING_MS[0]
424            ? RAMP[0]
425            : undefined;
426
427const text = (v: unknown) => (typeof v === 'string' && v ? v : undefined);
428
429// every session whose reply ended since `since`: those that left asks first, then those that finished β€”
430// a session the registry still shows busy has not finished; one that exited meanwhile has
431async function awayDigest($: EngineInterface, since: number): Promise<Digest> {
432    const needs: Digest['needs'] = [];
433    const done: Digest['done'] = [];
434    const keys = await $.store.keys();
435    const running = new Set(
436        (await registry($).catch(() => []))
437            .filter((r) => r.isAlive && statusOf(r.base.status).word === 'busy')
438            .map((r) => r.base.sid),
439    );
440    for (const key of keys) {
441        if (!key.startsWith(PREFIX)) continue;
442        const v = (await $.store.get(key)) as Entry | undefined;
443        if (!v || v.at < since || !v.asks.length) continue;
444        const sid = key.slice(PREFIX.length);
445        needs.push({ asks: v.asks.length, name: v.name ?? short(sid), sid });
446    }
447    for (const key of keys) {
448        if (!key.startsWith(REPLY)) continue;
449        const sid = key.slice(REPLY.length);
450        const v = (await $.store.get(key)) as Partial<Reply> | undefined;
451        if (
452            v?.at === undefined ||
453            v.at < since ||
454            running.has(sid) ||
455            needs.some((n) => n.sid === sid)
456        )
457            continue;
458        done.push({ name: v.name ?? short(sid), sid });
459    }
460    const byName = (a: { name: string }, b: { name: string }) =>
461        a.name.localeCompare(b.name);
462    return { done: done.sort(byName), needs: needs.sort(byName) };
463}
464
465type Registered = {
466    pid: number;
467    bg: boolean;
468    isAlive: boolean;
469    startedAt?: number;
470    base: Pick<Member, 'sid' | 'name' | 'status' | 'statusSince' | 'door'>;
471};
472
473// cc's registry stores the black bird's zero-width joiner as a space: «🐦 ⬛ ccrowΒ» for Β«πŸ¦β€β¬› ccrowΒ»
474const SPLIT_CROW = '\u{1F426} \u{2B1B}';
475const CROW = '\u{1F426}\u{200D}\u{2B1B}';
476
477// every session in cc's registry and whether its process still runs; a registry file mid-write is skipped
478async function registry($: EngineInterface): Promise<Registered[]> {
479    const dir = `${await $.env.get('HOME')}/.claude/sessions`;
480    const rows: Omit<Registered, 'isAlive'>[] = [];
481    for (const f of await $.fs.list(dir)) {
482        if (!f.name.endsWith('.json')) continue;
483        try {
484            const v = JSON.parse(await $.fs.read(`${dir}/${f.name}`)) as Record<
485                string,
486                unknown
487            >;
488            if (typeof v.pid !== 'number' || typeof v.sessionId !== 'string')
489                continue;
490            const bg = v.kind === 'bg';
491            rows.push({
492                base: {
493                    door: doorOf({
494                        bg,
495                        bridgeSessionId: text(v.bridgeSessionId),
496                        hostSessionId: text(v.hostSessionId),
497                        jobId: text(v.jobId),
498                    }),
499                    name:
500                        text(v.name)?.replaceAll(SPLIT_CROW, CROW) ??
501                        short(v.sessionId),
502                    sid: v.sessionId,
503                    status: text(v.status),
504                    statusSince:
505                        typeof v.statusUpdatedAt === 'number'
506                            ? v.statusUpdatedAt
507                            : undefined,
508                },
509                bg,
510                pid: v.pid,
511                startedAt:
512                    typeof v.startedAt === 'number' ? v.startedAt : undefined,
513            });
514        } catch {}
515    }
516    if (!rows.length) return [];
517    // ps prints the pids still alive and skips the rest
518    const ps = await $.process.run([
519        'ps',
520        '-o',
521        'pid=',
522        '-p',
523        rows.map((r) => r.pid).join(','),
524    ]);
525    const alive = new Set(
526        ps.stdout
527            .split('\n')
528            .map((s) => Number(s.trim()))
529            .filter(Boolean),
530    );
531    return rows.map((r) => ({ ...r, isAlive: alive.has(r.pid) }));
532}
533
534// live sessions from the registry, each with what its own stash wrote
535async function members($: EngineInterface): Promise<Member[]> {
536    const out: Member[] = [];
537    const now = await $.clock.now();
538    const shown = (await registry($)).filter(
539        (r) =>
540            r.isAlive &&
541            !HEADLESS.test(r.base.name) &&
542            !(r.startedAt !== undefined && now - r.startedAt < SHORT_MS),
543    );
544    for (const { bg, base: r } of shown) {
545        const reply = (await $.store.get(REPLY + r.sid)) as
546            | Partial<Reply>
547            | undefined;
548        const asks = (await $.store.get(PREFIX + r.sid)) as Entry | undefined;
549        out.push({
550            ...r,
551            asks: isLive(asks, now) ? asks.asks.length : 0,
552            context: (await $.store.get(CONTEXT + r.sid)) as number | undefined,
553            model: text(await $.store.get(MODEL + r.sid)),
554            offPattern: bg && !isFleetName(r.name),
555            wait: reply?.wait,
556        });
557    }
558    // the coordinator first, the rest by name
559    return out.sort(
560        (a, b) =>
561            Number(isCoordinator(b.name)) - Number(isCoordinator(a.name)) ||
562            a.name.localeCompare(b.name),
563    );
564}
565
566const span = (ms: number) => {
567    // a status stamped a moment ahead of this clock reads 0m, never -1m
568    const m = Math.max(0, Math.floor(ms / 60000));
569    return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
570};
571
572// each fact column's width in cells, sized to its usual reading β€” `⏳ 12`, `FRM-306`, `ctx 54%`, `idle 12m` β€” so little slack shows between columns; a longer one is cut at its end
573const COLUMNS = {
574    asks: 4,
575    context: 7,
576    state: 9,
577    ticket: 9,
578} as const;
579
580// the fleet's coordinator, pinned to the board's top in bold (dima, 2026-10-05)
581const isCoordinator = (name: string) => /\bcclio\b/.test(name);
582const doorGlyph = (door: Door) => (door.kind === 'open' ? 'β†—' : 'πŸ“‹');
583
584async function isOpen($: EngineInterface) {
585    const panes = await $.ui.panes().catch(() => []);
586    return panes.some((p) => p.id === BOARD);
587}
588
589async function openBoard($: EngineInterface) {
590    await $.ui.open({ closeOnEscape: true, id: BOARD, title: 'fleet board' });
591}
592
593async function pressDoor(
594    $: EngineInterface,
595    door: Door,
596    surface: Parameters<EngineInterface['ui']['copy']>[0]['surface'],
597) {
598    if (door.kind === 'copy') {
599        await $.ui.copy({ surface, text: door.text });
600        return;
601    }
602    const r = await $.process.run(['open', door.url]).catch(() => null);
603    if (r?.exitCode !== 0)
604        $.ui.log(`x-mod-stash board: could not open ${door.url}`);
605}
606
607// every control names itself on hover; the card stays on the control's row, since a one-row site clips the rest
608function hoverTip(
609    ui: ReturnType<EngineInterface['ui']['resolve']>,
610    key: string,
611    words: string,
612    control: RenderElement,
613    place: { left: number } | { right: number },
614    press?: { hotkey: string; onPress: () => void },
615) {
616    const { Box, Text, Button } = ui;
617    return (
618        <Box key={`tip:${key}`}>
619            {control}
620            <Box
621                display='none'
622                hover={{ display: 'flex' }}
623                position='absolute'
624                top={0}
625                {...place}>
626                {/* the key rides the card, not the icon: the surface draws its own key badge beside a Button with a hotkey */}
627                {press ? (
628                    <Button
629                        dimColor
630                        hotkey={press.hotkey}
631                        key={`key:${key}`}
632                        onPress={press.onPress}
633                        plain>
634                        {words}
635                    </Button>
636                ) : (
637                    <Text dimColor>{words}</Text>
638                )}
639            </Box>
640        </Box>
641    );
642}
643
644export const register: Register = (on) => {
645    // session.start fires at startup and again on every hot reload; the classic event only at startup
646    on('session.start', async ($, e, next) => {
647        // the repo root, not the cwd: a cd in the shell must not rename the thread
648        selfId = await $.session.id();
649        const top = (await $.session.repo())?.root ?? e.cwd;
650        label = basename(top);
651        proc = await procOf($).catch(() => {
652            $.ui.log(
653                'x-mod-stash: no pid for this session, so its row reads no registry name',
654            );
655            return undefined;
656        });
657        // what a reload finds in `$.state`, read once: every get of one dispatch reads the moment it began
658        const kept = {
659            cap: (await $.state.get(CAP)).value,
660            open: (await $.state.get(OPEN)).value,
661            turn: (await $.state.get(TURN)).value,
662            view: (await $.state.get(VIEW)).value,
663        };
664        cap = kept.cap ?? undefined;
665        await pruneEnded($).catch(() => undefined);
666        hot = (await $.store.get(HOT + selfId)) as typeof hot;
667        if (kept.open !== undefined) open = kept.open;
668        // a mid-turn reload: the turn goes on, busy, and the band keeps what only it held
669        if (kept.turn) {
670            isBusy = kept.turn.isBusy;
671            isUserTurn = kept.turn.isUserTurn;
672            pinged = kept.turn.isPinged;
673            turnAfk = kept.turn.afk;
674        }
675        digest = kept.view?.digest;
676        await load($);
677        await armHot($);
678        await armWake($);
679        await $.command
680            .register({
681                description:
682                    'open the fleet board: every live session, busy or idle, its last message; `/board colour` flips its colour',
683                name: 'board',
684            })
685            .catch(() => $.ui.log('x-mod-stash: /board was not registered'));
686        if (!isPolling) {
687            isPolling = true;
688            const tick = () =>
689                $.clock.after(POLL_MS, async () => {
690                    // an open board's view carries the tick's clock, so its Β«agoΒ» times move on their own
691                    await load($);
692                    tick();
693                });
694            tick();
695        }
696        return next(e);
697    });
698
699    // only dima's own hands make his turn; a reply to any other origin may drop the block
700    on('prompt.submit', async ($, e, next) => {
701        isUserTurn =
702            e.text.trim().length > 0 &&
703            (DIMA_ORIGINS as readonly string[]).includes(e.origin?.kind ?? '');
704        pinged = e.text === PING;
705        // dima read the away digest once he types again
706        if (isUserTurn) digest = undefined;
707        isBusy = true;
708        hotGen++;
709        await load($);
710        if (isUserTurn)
711            await queueAfter($, e.text).catch(() =>
712                $.ui.log('x-mod-stash: a Β«yes, afterΒ» verdict was not queued'),
713            );
714        turnAfk = afk;
715        await keepTurn($);
716        // the clock rides every prompt, so a reply's πŸ“„ stamp copies it instead of guessing
717        const clock = `now ${new Date(await $.clock.now()).toTimeString().slice(0, 5)}`;
718        return next({
719            ...e,
720            context: [...(e.context ?? []), clock, ...(afk ? [AWAY_NOTE] : [])],
721        });
722    });
723
724    // a long turn outruns the prompt's clock: the stored reply's πŸ“„ stamp is the time it was written
725    on('session.append', { door: 'response' }, async ($, e, next) => {
726        const now = new Date(await $.clock.now()).toTimeString().slice(0, 5);
727        let isChanged = false;
728        const hits: Record<string, number> = {};
729        const content = e.message.content.map((b) => {
730            if (b.type !== 'text' || typeof b.text !== 'string') return b;
731            // the stored row is what the Stop hooks read: a Β·-list fixed here never trips reply-check
732            const fixed = boldFleetWords(bulletDots(b.text));
733            for (const [word, n] of Object.entries(fixed.hits))
734                hits[word] = (hits[word] ?? 0) + n;
735            const bulleted = fixed.text;
736            if (bulleted !== b.text) isChanged = true;
737            const text = bulleted.replace(
738                STAMP,
739                (whole: string, head: string, at: string) => {
740                    if (at === now) return whole;
741                    isChanged = true;
742                    return `${head}${now}`;
743                },
744            );
745            return { ...b, text };
746        });
747        if (Object.keys(hits).length)
748            await countWords($, hits).catch((err) =>
749                $.ui.log(
750                    `x-mod-stash: the fleet-word count was not kept: ${errorText(err)}`,
751                ),
752            );
753        if (!isChanged) return next(e);
754        return next({ ...e, message: { ...e.message, content } });
755    });
756
757    // the drawing: the reply's Β·-list shows as bullets while it streams, before the stored row exists
758    on('ui.render', { component: 'AssistantMessage' }, async (_$, e, next) => {
759        const { text } = boldFleetWords(bulletDots(e.props.text));
760        if (text === e.props.text) return next(e);
761        return next({ ...e, props: { ...e.props, text } });
762    });
763
764    on('classic.Stop', async ($, e, next) => {
765        const r = await next(e);
766        const reply = e.last_assistant_message ?? '';
767        // a keep-hot ping's one-character reply never clears what the session waits on
768        if (!pinged)
769            await $.store
770                .set(REPLY + e.session_id, {
771                    at: await $.clock.now(),
772                    name: await sessionName($),
773                    wait: parseWait(reply),
774                } satisfies Reply)
775                .catch(() => undefined);
776        const asks = parseAsks(reply);
777        // a reply to dima with no block means nothing is open; a reply woken by a peer keeps the old list
778        if (asks !== null || isUserTurn) {
779            const key = PREFIX + e.session_id;
780            if (asks?.length)
781                await $.store.set(key, {
782                    asks,
783                    at: await $.clock.now(),
784                    label,
785                    name: await sessionName($),
786                });
787            else await $.store.delete(key);
788            await load($);
789        }
790        isUserTurn = false;
791        await keepTurn($);
792        // warn, never block: the note reaches the model with the event
793        const nested = nestedAsks(reply);
794        if (!nested.length) return r;
795        const items = nested.map((n) => `item ${n}`).join(', ');
796        return {
797            ...r,
798            additionalContext: [
799                ...(r.additionalContext ?? []),
800                `x-mod-stash: the ⏳ block's ${items} carries nested lines β€” one line per item (rules/fleet-output-format.md, ⏳ open asks), so Β«cΒ» copies it whole; fold the detail into the line or move it above the block`,
801            ],
802        };
803    });
804
805    on('turn.complete', async ($, e, next) => {
806        const r = await next(e);
807        // a subagent's turn ending is not the session going idle
808        if (e.agentId) return r;
809        isBusy = false;
810        await keepTurn($);
811        // a stop on the 5h cap waits for the reset; any other end clears it
812        cap =
813            e.reason === 'error' && CAPPED.test(e.answer)
814                ? { resetsAt: fiveHourResetsAt }
815                : undefined;
816        await $.state.set(CAP, cap ?? null).catch(() => undefined);
817        await armWake($);
818        // the store is πŸ”₯'s truth: ccrow writes its key after session.start, and a deleted key turns it off
819        const sid = await currentId($);
820        if (sid)
821            hot = (await $.store.get(HOT + sid).catch(() => hot)) as typeof hot;
822        if (hot) {
823            hot = { ...hot, since: await $.clock.now() };
824            await saveHot($);
825        }
826        await armHot($);
827        await load($);
828        return r;
829    });
830
831    // the context fill the board shows, and the 5h reset the waker waits on
832    on('session.measure', async ($, e, next) => {
833        const w = e.rateLimits.find((r) => r.kind === 'five_hour');
834        const resetsAt = w?.resetsAt ? Date.parse(w.resetsAt) : undefined;
835        if (resetsAt !== undefined) {
836            fiveHourResetsAt = resetsAt;
837            // capped before any measure named the reset: wait for this one
838            if (cap && cap.resetsAt === undefined) {
839                cap = { ...cap, resetsAt };
840                await $.state.set(CAP, cap).catch(() => undefined);
841                await armWake($);
842            }
843        }
844        const sid = await currentId($);
845        if (sid && e.context.percent !== undefined)
846            await $.store
847                .set(CONTEXT + sid, e.context.percent)
848                .catch(() => undefined);
849        return next(e);
850    });
851
852    // the board shows each session's model and effort as its main loop's last request named them; a subagent's step never counts
853    on('turn.step', async function* ($, e, next) {
854        if (!e.agentId) {
855            const sid = await currentId($);
856            const said = modelLabel(e.model, e.effort);
857            if (sid && modelSeen !== `${sid}\u0000${said}`) {
858                modelSeen = `${sid}\u0000${said}`;
859                await $.store.set(MODEL + sid, said).catch(() => undefined);
860            }
861        }
862        return yield* next(e);
863    });
864
865    // `/board colour` flips the colour MVP for every session's board, and opens it to look
866    on('command.run', { command: 'board' }, async ($, e) => {
867        const isColourFlip = /^colou?r$/.test(e.args.trim());
868        if (isColourFlip)
869            await $.store.set(COLOUR, !(await $.store.get(COLOUR)));
870        await openBoard($);
871        await publish($);
872        if (!isColourFlip) return { text: 'fleet board opened' };
873        return {
874            text: `fleet board colour ${(await $.store.get(COLOUR)) ? 'on' : 'off'}`,
875        };
876    });
877
878    // the row's 🚦 shows whether the board is open
879    on('ui.close', async ($, e, next) => {
880        const r = await next(e);
881        if (e.id === BOARD) await publish($);
882        return r;
883    });
884
885    on('ui.render', { component: 'Pane', requestId: BOARD }, async ($, e) => {
886        const ui = $.ui.resolve(e);
887        const { Box, Text, Button, Link } = ui;
888        // the poll read the registry into the view; the pane only draws it, so a draw costs no file or process read
889        const board = (await $.state.get(BOARD_VIEW)).value;
890        const now = board?.at ?? 0;
891        const me = (await $.state.get(VIEW)).value?.selfId;
892        const list = board?.members;
893        if (!list)
894            return <Text dimColor>the session registry is unreadable</Text>;
895        if (!list.length) return <Text dimColor>no live sessions</Text>;
896        const isColour = board.isColour;
897        const hues = huesOf(list.map((m) => m.name));
898        const row = (m: Member, i: number) => {
899            const { tint: stateTint, word: state } = statusOf(m.status);
900            const isMemberBusy = state === 'busy';
901            const sinceMs =
902                m.statusSince === undefined ? undefined : now - m.statusSince;
903            const idleMs =
904                state === 'idle' && sinceMs !== undefined ? sinceMs : 0;
905            const isCold = idleMs >= COLD_MS;
906            const coolTint = idleTint(idleMs);
907            const stateText =
908                sinceMs === undefined
909                    ? state
910                    : `${isCold ? '❄️' : state} ${span(sinceMs)}`;
911            const ticket = ticketOf(m.name);
912            const door = m.door;
913            const name = m.sid === me ? `${m.name} (here)` : m.name;
914            const line = m.wait && `πŸ”­ ${m.wait}`;
915            return (
916                <Box
917                    flexDirection='column'
918                    key={`m:${m.sid}`}
919                    marginTop={i > 0 ? 1 : 0}>
920                    <Box
921                        flexDirection='row'
922                        gap={1}
923                        justifyContent='space-between'>
924                        {/* the name side gives way, clipped, so a long name never pushes the fact columns out of line */}
925                        <Box
926                            flexDirection='row'
927                            flexGrow={1}
928                            flexShrink={1}
929                            gap={1}
930                            minWidth={0}
931                            overflow='hidden'>
932                            {/* the dot leads the row, the reference's cue: with colour on, the member's own hue, dimmed while idle */}
933                            <Box flexShrink={0}>
934                                <Text
935                                    color={isColour ? hues[i] : undefined}
936                                    dimColor={!isColour || state === 'idle'}>
937                                    ●
938                                </Text>
939                            </Box>
940                            {/* the whole name is the control; the coordinator's name stays bold text, since a Button label takes no weight */}
941                            {door && !isCoordinator(m.name) ? (
942                                <Button
943                                    key={`door:${m.sid}`}
944                                    onPress={(p) =>
945                                        void pressDoor($, door, p.surface)
946                                    }
947                                    plain>
948                                    {`${name} ${doorGlyph(door)}`}
949                                </Button>
950                            ) : (
951                                <Text
952                                    bold={isCoordinator(m.name)}
953                                    wrap='truncate-end'>
954                                    {name}
955                                </Text>
956                            )}
957                            {door && isCoordinator(m.name) ? (
958                                <Button
959                                    key={`door:${m.sid}`}
960                                    onPress={(p) =>
961                                        void pressDoor($, door, p.surface)
962                                    }
963                                    plain>
964                                    {doorGlyph(door)}
965                                </Button>
966                            ) : null}
967                            {m.offPattern ? (
968                                <Text color={ACCENT}>⚠</Text>
969                            ) : null}
970                        </Box>
971                        {/* one fixed-width, right-aligned cell per fact, drawn empty when it has no reading, so the columns line up across rows (dima, 2026-10-05) */}
972                        <Box
973                            flexDirection='row'
974                            flexShrink={0}
975                            gap={e.surface === 'desktop' ? 0.5 : 1}>
976                            {/* no hover card in the pane: an absolute card in a narrow row wraps and clips (dima, 2026-10-05) */}
977                            <Box
978                                justifyContent='flex-end'
979                                width={COLUMNS.ticket}>
980                                {ticket ? (
981                                    // never wraps: `FRM-306` broke at its hyphen in the desktop font
982                                    <Text dimColor wrap='truncate-end'>
983                                        <Link
984                                            href={`https://linear.app/x-com/issue/${ticket}`}>
985                                            {ticket}
986                                        </Link>
987                                    </Text>
988                                ) : null}
989                            </Box>
990                            <Box
991                                justifyContent='flex-end'
992                                width={COLUMNS.context}>
993                                {m.context === undefined ? null : (
994                                    <Text
995                                        color={
996                                            isColour
997                                                ? contextTint(m.context)
998                                                : undefined
999                                        }
1000                                        dimColor={
1001                                            !isColour || !contextTint(m.context)
1002                                        }>
1003                                        ctx {m.context}%
1004                                    </Text>
1005                                )}
1006                            </Box>
1007                            <Box
1008                                justifyContent='flex-end'
1009                                width={COLUMNS.state}>
1010                                <Text
1011                                    bold={isMemberBusy}
1012                                    color={
1013                                        coolTint ??
1014                                        (isColour || isMemberBusy
1015                                            ? stateTint
1016                                            : undefined)
1017                                    }
1018                                    dimColor={state === 'idle' && !coolTint}
1019                                    wrap='truncate-end'>
1020                                    {stateText}
1021                                </Text>
1022                            </Box>
1023                            {/* the asks close the row, always drawn: `⏳ 0` dimmed when none (dima, 2026-10-05) */}
1024                            <Box justifyContent='flex-end' width={COLUMNS.asks}>
1025                                <Text
1026                                    color={m.asks ? ACCENT : undefined}
1027                                    dimColor={!m.asks}>
1028                                    ⏳ {m.asks}
1029                                </Text>
1030                            </Box>
1031                        </Box>
1032                    </Box>
1033                    {/* the second line sits under the name, past the dot, dim β€” the reference's: the model first, kept whole, then the wait, cut at its end */}
1034                    {m.model || line ? (
1035                        <Box flexDirection='row' gap={2} paddingLeft={2}>
1036                            {m.model ? (
1037                                <Box flexShrink={0}>
1038                                    <Text dimColor>{m.model}</Text>
1039                                </Box>
1040                            ) : null}
1041                            {line ? (
1042                                <Box flexShrink={1} minWidth={0}>
1043                                    <Text dimColor wrap='truncate-end'>
1044                                        {line}
1045                                    </Text>
1046                                </Box>
1047                            ) : null}
1048                        </Box>
1049                    ) : null}
1050                </Box>
1051            );
1052        };
1053        const busyCount = list.filter(
1054            (m) => statusOf(m.status).word === 'busy',
1055        ).length;
1056        return (
1057            <Box flexDirection='column'>
1058                {/* the head: what the pane holds on the left, how many work right now on the right */}
1059                <Box
1060                    flexDirection='row'
1061                    justifyContent='space-between'
1062                    marginBottom={1}>
1063                    <Box flexDirection='row' gap={1}>
1064                        <Text>🚦</Text>
1065                        <Text bold>sessions</Text>
1066                        <Text dimColor>on this mac Β· {list.length}</Text>
1067                    </Box>
1068                    <Text
1069                        color={busyCount ? STATUS.busy.tint : undefined}
1070                        dimColor={!busyCount}>
1071                        {busyCount} busy
1072                    </Text>
1073                </Box>
1074                {list.map(row)}
1075                <Box marginTop={1}>
1076                    <Text dimColor wrap='truncate-end'>
1077                        a name opens its session Β· /board colour flips colour
1078                    </Text>
1079                </Box>
1080            </Box>
1081        );
1082    });
1083
1084    // a /clear or a resume leaves the conversation behind; an exit leaves its last reply for the away digest
1085    on('session.end', async ($, e, next) => {
1086        if (e.reason === 'clear' || e.reason === 'resume')
1087            await forget($, e.sessionId);
1088        else await retire($, e.sessionId);
1089        return next(e);
1090    });
1091
1092    // `/clear` and `/resume` leave the conversation: its asks and holds go now, not at the next poll
1093    on('command.run', async ($, e, next) => {
1094        if (e.command !== 'clear' && e.command !== 'resume') return next(e);
1095        const left = await currentId($);
1096        const r = await next(e);
1097        if (left) await forget($, left).catch(() => undefined);
1098        await load($);
1099        return r;
1100    });
1101
1102    // afk flipped mid-turn: the next tool result carries the new state once
1103    on('tool.call', async (_$, e, next) => {
1104        const r = await next(e);
1105        if (turnAfk === undefined || turnAfk === afk || r.deny !== undefined)
1106            return r;
1107        turnAfk = afk;
1108        return {
1109            ...r,
1110            context: [...(r.context ?? []), afk ? AWAY_NOTE : BACK_NOTE],
1111        };
1112    });
1113
1114    on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1115        if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e);
1116        // the published view, never the module's values: reading it subscribes this band, and a write redraws it
1117        const view = (await $.state.get(VIEW)).value ?? viewOf(false);
1118        const isOpenList = (await $.state.get(OPEN)).value ?? open;
1119        const groups = Object.entries(view.entries).sort(([a], [b]) =>
1120            a === view.selfId ? -1 : b === view.selfId ? 1 : 0,
1121        );
1122        const total = groups.reduce((n, [, v]) => n + v.asks.length, 0);
1123        const ui = $.ui.resolve(e);
1124        const { Box, Text, Button } = ui;
1125        const surface = e.surface;
1126        // a thread reads its session name; the repo joins only when two sessions share one
1127        const title = (v: Entry) => {
1128            const base = v.name ?? v.label;
1129            const shared = groups.filter(
1130                ([, o]) => (o.name ?? o.label) === base,
1131            ).length;
1132            return shared > 1 && v.name ? `${base} Β· ${v.label}` : base;
1133        };
1134        // the head stays short; the per-session breakdown lives in its hover card
1135        const here = view.entries[view.selfId ?? '']?.asks.length ?? 0;
1136        const counts = `here ${here}, parallel ${total - here}`;
1137        const breakdown = groups
1138            .map(
1139                ([sid, e]) =>
1140                    `${sid === view.selfId ? `${title(e)} (here)` : title(e)} ${e.asks.length}`,
1141            )
1142            .join(', ');
1143        const handleFoldToggle = () => {
1144            open = !isOpenList;
1145            void $.state.set(OPEN, open).catch(() => undefined);
1146        };
1147        const handleAfkFlip = async () => {
1148            const was = (await $.store.get(AFK_KEY)) as
1149                | { at?: number; on?: boolean }
1150                | undefined;
1151            afk = !afk;
1152            digest =
1153                !afk && was?.on && was.at !== undefined
1154                    ? await awayDigest($, was.at)
1155                    : undefined;
1156            await $.store.set(AFK_KEY, { at: await $.clock.now(), on: afk });
1157            await publish($);
1158        };
1159        // one switch for every session, kept in the store so a restart keeps it
1160        const handleWakerFlip = async () => {
1161            isWaker = !view.isWaker;
1162            await $.store.set(WAKER_KEY, { on: isWaker });
1163            await publish($);
1164        };
1165        const handleHotFlip = async () => {
1166            hot = hot ? undefined : { since: await $.clock.now() };
1167            await saveHot($);
1168            await armHot($);
1169            await publish($);
1170        };
1171        const tip = (
1172            key: string,
1173            words: string,
1174            control: RenderElement,
1175            place: { left: number } | { right: number },
1176            press?: { hotkey: string; onPress: () => void },
1177        ) => hoverTip(ui, key, words, control, place, press);
1178        const boardOpen = view.isBoardOpen;
1179        const handleBoardFlip = async () => {
1180            if (await isOpen($)) await $.ui.close({ id: BOARD });
1181            else await openBoard($);
1182            await publish($);
1183        };
1184        // a terminal draws a chromed Button as `[ label ]`
1185        const leftOf = (label: string, chrome: boolean) => ({
1186            right: [...label].length + (chrome ? 4 : 0) + 1,
1187        });
1188        // only the head's copy takes `c`: two Buttons on one key clash, and the later wins
1189        const copyButton = (sid: string, asks: string[], hotkey?: string) => {
1190            const handleCopy = () =>
1191                void $.ui.copy({
1192                    surface,
1193                    text: [
1194                        'lane',
1195                        ...asks.map((a, i) => `${i + 1}. ${a}`),
1196                    ].join('\n'),
1197                });
1198            return tip(
1199                `copy:${sid}`,
1200                "copy this thread's asks",
types/stash.d.ts 77 lines
1// one session's open asks, as its last reply left them
2export type StashEntry = {
3    label: string;
4    name?: string;
5    asks: string[];
6    at: number;
7};
8
9// the session's last main turn stopped on the 5h cap: the reset it waits for, and the reset a resume already went for
10export type StashCap = { resetsAt?: number; sentFor?: number };
11
12// what the fleet did while dima was afk
13export type StashDigest = {
14    needs: { sid: string; name: string; asks: number }[];
15    done: { sid: string; name: string }[];
16};
17
18// what a press on a board row's name does: open a desktop url, or copy the terminal door
19export type StashDoor =
20    | { kind: 'open'; url: string }
21    | { kind: 'copy'; text: string };
22
23// one row of the fleet board
24export type StashMember = {
25    sid: string;
26    name: string;
27    status?: string;
28    statusSince?: number;
29    door?: StashDoor;
30    wait?: string;
31    asks: number;
32    context?: number;
33    model?: string;
34    offPattern: boolean;
35};
36
37// everything the band draws, written by the poll and the hooks; a render reads it and redraws on a write
38export type StashView = {
39    selfId?: string;
40    entries: Record<string, StashEntry>;
41    afk: boolean;
42    isHot: boolean;
43    isWaker: boolean;
44    digest?: StashDigest;
45    isBoardOpen: boolean;
46};
47
48// what only the board pane draws, kept apart from the band's view: its clock moves every poll, and a band that read it
49// would redraw β€” rebuilding x-mod-breather's svg (FRM-354)
50export type StashBoardView = {
51    members?: StashMember[];
52    isColour: boolean;
53    // the poll's clock, so the board's Β«busy 3mΒ» spans move on their own
54    at: number;
55};
56
57// the running turn, kept so a mid-turn reload still knows it: busy, dima's own, a keep-hot ping, afk as it began
58export type StashTurn = {
59    isBusy: boolean;
60    isUserTurn: boolean;
61    isPinged: boolean;
62    afk?: boolean;
63};
64
65declare module 'claude-code' {
66    interface PluginState {
67        'x-mod-stash': {
68            open: boolean;
69            view: StashView;
70            board: StashBoardView;
71            turn: StashTurn;
72            // null: the last main turn did not stop on the cap
73            cap: StashCap | null;
74        };
75    }
76}
77
hooks/parse.ts 217 lines
1import type { StashDoor } from '../types/stash.d.ts';
2
3const HEADER = /⏳\s*waiting on your word/i;
4
5// a Β·-joined list in prose, rewritten as bullets the way the output rules want it (reply-check blocks the dot);
6// fences, inline code and the πŸ“„ stamp line keep theirs
7export function bulletDots(text: string) {
8    let isFence = false;
9    return text
10        .split('\n')
11        .flatMap((line) => {
12            if (/^\s*```/.test(line)) isFence = !isFence;
13            if (isFence || line.includes('πŸ“„')) return [line];
14            // inline code masked at the same length, so split offsets land on the real line
15            const masked = line.replace(/`[^`\n]*`/g, (m) =>
16                '\0'.repeat(m.length),
17            );
18            if (!/\S\sΒ·\s\S/.test(masked)) return [line];
19            const parts: string[] = [];
20            let from = 0;
21            for (const m of masked.matchAll(/\sΒ·\s/g)) {
22                parts.push(line.slice(from, m.index));
23                from = (m.index ?? 0) + m[0].length;
24            }
25            parts.push(line.slice(from));
26            const [indent, bullet, head] = (parts[0] ?? '')
27                .match(/^(\s*)(-\s+)?(.*)$/)
28                ?.slice(1) ?? ['', undefined, ''];
29            const pad = bullet ? `${indent}  ` : (indent ?? '');
30            // a label ends at the first item's last colon: `the crew: a Β· b` keeps `the crew:` as its line
31            const colon = (head ?? '').lastIndexOf(': ');
32            const label =
33                colon >= 0 ? (head ?? '').slice(0, colon + 1) : undefined;
34            const items = [
35                colon >= 0 ? (head ?? '').slice(colon + 2) : (head ?? ''),
36                ...parts.slice(1),
37            ];
38            const lines = items.map((i) => `${pad}- ${i.trim()}`);
39            if (label !== undefined)
40                return [`${indent}${bullet ?? ''}${label}`, ...lines];
41            return bullet ? [`${indent}${bullet}`.trimEnd(), ...lines] : lines;
42        })
43        .join('\n');
44}
45
46// a fleet word or member prints bold with its badge glued on (rules/fleet-output-format.md); a bare one in prose is fixed,
47// not policed. fences, quotes, inline code, bold text and links keep theirs; Β«wishΒ» only as a noun, after a determiner.
48// members are rules/fleet-identity.md's, minus the names that are plain english too (helper, retro, classifier, checkup,
49// explore, cc) β€” those would badge a helper function or the retro file
50const MEMBERS = {
51    cclio: 'πŸ¦‰',
52    ccrow: 'πŸ¦β€β¬›',
53    coder: 'πŸ”§',
54    cw: '🀝',
55    designer: '🎨',
56    dima: 'πŸ™‹β€β™‚οΈ',
57    researcher: '🐝',
58    sifter: 'πŸͺΆ',
59    verifier: 'πŸ”Ž',
60} as const;
61const BADGES = {
62    ...MEMBERS,
63    freebie: 'πŸ€',
64    siesta: '🌀️',
65    wish: '🌠',
66    wisp: '✨',
67} as const;
68type FleetWord = keyof typeof BADGES;
69const WORD = new RegExp(
70    `(?<![\\w\\-/.])(?:(${Object.values(BADGES).join('|')}) )?(${Object.keys(BADGES).join('|')})(s|es)?(?![\\w\\-/]|[.:]\\w)`,
71    'giu',
72);
73const DETERMINER =
74    /(?:^|\s)(?:a|an|the|his|her|this|that|each|every|one|new|your|my|our|their|dima's)\s+(?:🌠 )?$/i;
75
76export function boldFleetWords(text: string) {
77    const hits: Partial<Record<FleetWord, number>> = {};
78    let isFence = false;
79    const lines = text.split('\n').map((line) => {
80        if (/^\s*```/.test(line)) isFence = !isFence;
81        if (isFence || /^\s*>/.test(line)) return line;
82        // masked at the same length, so match offsets land on the real line; a Β«quoteΒ» stays as typed, and a session
83        // name (`β˜•οΈ πŸ”§ mods coder`) keeps its own role emoji
84        const masked = line.replace(
85            /`[^`\n]*`|\*\*[^*\n]+\*\*|\]\([^)\n]*\)|https?:\/\/\S+|Β«[^Β»\n]*Β»|(?:β˜•οΈ?|🎯) \S+ [^\n,;)Β»]*/gu,
86            (m) => '\0'.repeat(m.length),
87        );
88        let out = '';
89        let from = 0;
90        for (const m of masked.matchAll(WORD)) {
91            const at = m.index ?? 0;
92            const word = (m[2] ?? '').toLowerCase() as FleetWord;
93            if (word === 'wish' && !DETERMINER.test(masked.slice(0, at)))
94                continue;
95            const badge = BADGES[word];
96            // another word's badge before it is someone else's label: leave both
97            if (m[1] && m[1] !== badge) continue;
98            const shown = m[0].slice(m[1] ? m[1].length + 1 : 0);
99            out += `${line.slice(from, at)}**${badge} ${shown}**`;
100            from = at + m[0].length;
101            hits[word] = (hits[word] ?? 0) + 1;
102        }
103        return out + line.slice(from);
104    });
105    return { hits, text: lines.join('\n') };
106}
107
108// dima's Β«yes, after XΒ» verdicts, each with the open ask its number names; a plain yes is not one
109export function parseAfter(prompt: string, asks: string[]) {
110    return prompt.split('\n').flatMap((line) => {
111        const m = line.match(/^\s*(\d+)\.\s.*?\byes,?\s+after\s+(.+?)\s*$/i);
112        const ask = m && asks[Number(m[1]) - 1];
113        return m?.[2] && ask ? [{ after: m[2], ask }] : [];
114    });
115}
116
117// The asks of a reply's ⏳ block, in order; null when the reply carries no block.
118export function parseAsks(reply: string): string[] | null {
119    const start = reply.search(HEADER);
120    if (start < 0) return null;
121    const fence = reply.indexOf('```', start);
122    if (fence < 0) return null;
123    const end = reply.indexOf('```', fence + 3);
124    const body = reply.slice(
125        reply.indexOf('\n', fence) + 1,
126        end < 0 ? undefined : end,
127    );
128    const asks: string[] = [];
129    for (const line of body.split('\n')) {
130        if (/^\s*wispr adds\s*$/i.test(line)) break;
131        const m = line.match(/^\s*\d+\.\s+(.+?)\s*$/);
132        if (m?.[1]) asks.push(m[1]);
133    }
134    return asks;
135}
136
137// The numbers of the ⏳ asks that carry lines under them; the rule is one line per item, so «c» copies it whole.
138export function nestedAsks(reply: string): string[] {
139    const start = reply.search(HEADER);
140    if (start < 0) return [];
141    const fence = reply.indexOf('```', start);
142    if (fence < 0) return [];
143    const end = reply.indexOf('```', fence + 3);
144    const body = reply.slice(
145        reply.indexOf('\n', fence) + 1,
146        end < 0 ? undefined : end,
147    );
148    const nested = new Set<string>();
149    let item: string | undefined;
150    for (const line of body.split('\n')) {
151        if (/^\s*wispr adds\s*$/i.test(line)) break;
152        const m = line.match(/^\s*(\d+)\.\s+\S/);
153        if (m?.[1]) item = m[1];
154        else if (line.trim() && item) nested.add(item);
155    }
156    return [...nested];
157}
158
159// a reply line as a person reads it: links reduced to their labels, no bold or code marks, no list or heading marker
160function plain(line: string) {
161    return line
162        .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
163        .replace(/\*\*|`/g, '')
164        .replace(/^\s*(?:[-*]|#+)\s+/, '')
165        .trim();
166}
167
168// What a reply says its session waits on: the πŸ”­ lines that end it, joined; a πŸ”­ quoted earlier, or in a fence, is not one.
169export function parseWait(reply: string): string | undefined {
170    const waits: string[] = [];
171    for (const line of reply.trimEnd().split('\n').reverse()) {
172        const l = line.trim();
173        if (!l) continue;
174        if (!l.startsWith('πŸ”­')) break;
175        waits.unshift(plain(l.slice('πŸ”­'.length)));
176    }
177    return waits.length ? waits.join('; ') : undefined;
178}
179
180export const ticketOf = (name: string) => name.match(/\b[A-Z]{2,5}-\d+\b/)?.[0];
181
182// the desktop's url handler accepts only these id shapes (Claude.app 2.1.289)
183const LOCAL_ID = /^local_[A-Za-z0-9-]{1,64}$/;
184const BRIDGE_ID = /^session_[A-Za-z0-9_-]+$/;
185const JOB_ID = /^[A-Za-z0-9-]+$/;
186
187export type Door = StashDoor;
188
189// How a press reaches a session: its desktop deep link, else the terminal's attach command for a background job.
190export function doorOf(entry: {
191    hostSessionId?: string;
192    bridgeSessionId?: string;
193    jobId?: string;
194    bg: boolean;
195}): Door | undefined {
196    if (entry.hostSessionId && LOCAL_ID.test(entry.hostSessionId))
197        return {
198            kind: 'open',
199            url: `claude://code/continue?session=${entry.hostSessionId}`,
200        };
201    if (entry.bridgeSessionId && BRIDGE_ID.test(entry.bridgeSessionId))
202        return { kind: 'open', url: `claude://code/${entry.bridgeSessionId}` };
203    if (entry.bg && entry.jobId && JOB_ID.test(entry.jobId))
204        return { kind: 'copy', text: `claude attach ${entry.jobId}` };
205    return undefined;
206}
207
208// `<mode> <role emoji> <ticket> <role word>: <what>` β€” Β«β˜•οΈ πŸ”§ FRM-303 code: stash keep-hotΒ»; a standing member keeps
209// its own name, badge or not β€” Β«πŸ¦β€β¬› ccrowΒ», which the registry writes with a space for the joiner
210const FLEET_NAME = /^(β˜•οΈ?|🎯)\s+\S+\s+[A-Z]{2,5}-\d+\s+[\w-]+:\s+\S/u;
211export function isFleetName(name: string) {
212    return (
213        FLEET_NAME.test(name) ||
214        Object.hasOwn(MEMBERS, name.replace(/[^\w\s]/gu, '').trim())
215    );
216}
217