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)β¦

ποΈ 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">
home/ is ~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 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">
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">
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
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.
hooks/register.tsx 1394 lines1/* @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 lines1// 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}
77hooks/parse.ts 217 lines1import 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