Every open Claude Code session in a pane of its own, ordered by who needs you: needs you, your turn, working, idle.

Three mods for Claude Code, made by Sgript. Built and tested on Claude Code 2.1.296.
| Mod | What it does |
|---|---|
| sprout | A pixel creature above the prompt that acts out what Claude is doing: thinking, reading, writing, running commands, waiting on you. It evolves as the session's context window fills (under 40%, 40 to 75%, over 75%) and drops back after /compact. Choose who it is with /avatar. |
| caffeinate | Keeps the Mac awake while a turn runs longer than a minute or background work is going. /caffeinate shows what it's doing; /caffeinate on, off or auto changes it. macOS only. |
| session-hub | A Sessions pane listing every open Claude Code session, ordered by who needs you. /sessions opens it; /sessions 3 jumps to the third; /sessions new starts one. Jumping to a session needs the Warp terminal. |
Sprout works on its own. With caffeinate installed it shows a coffee cup while the Mac is held awake; with session-hub it signals when another session needs you or has finished.
Sprout can be any of three lines, each with three forms:
/avatar | Forms | Gets about by | Attacks |
|---|---|---|---|
toad | Sprout, Bud, Bloom | walking | Vine Lash, Leaf Volley, Sunbeam |
lizard | Spark, Blaze, Inferno | walking side-on | Ember, Flamethrower, Fire Burst |
fox | Doze, Spoon, Sage | teleporting | Teleport, Mind Beam, Mind Wave |
Run /avatar on its own to pick from a list, or name one: /avatar lizard (any form's name works too, such as /avatar inferno). The choice is kept for every session after. The toad line is four terminal rows tall; the other two are six.
Open docs/sprite-lab.html in a browser to see every animation for every form, a tab per line.
Start Claude Code in a terminal (the install command doesn't work in the desktop app's Code tab), add this repo as a marketplace, then install the mods you want:
/plugin marketplace add sgript-labs/claude-mods
/plugin install sprout@sgript-labs
/plugin install caffeinate@sgript-labs
/plugin install session-hub@sgript-labs
Choose the user scope when asked. Sprout appears above the prompt straight away. Installed at the user scope, the mods also run in new desktop app sessions.
From a shell, the same is:
claude plugin marketplace add sgript-labs/claude-mods
claude plugin install sprout@sgript-labs
To remove one, run /plugin and uninstall it from there.
Clone the repo, then point Claude Code at the mod folders by setting CLAUDE_CODE_PLUGIN_DIRS under env in ~/.claude/settings.json:
"CLAUDE_CODE_PLUGIN_DIRS": "~/repos/claude-mods/sprout:~/repos/claude-mods/caffeinate:~/repos/claude-mods/session-hub"
Terminal sessions reload a mod when its files change; desktop app sessions need a new session.
Run a mod's tests, and check its manifest:
claude plugin test ./sprout
claude plugin validate --strict ./sprout
After changing Sprout's sprites, rebuild the sprite lab:
bun sprout/tools/gallery.ts docs/sprite-lab.html
Every name Sprout shows (lines, forms and moves) is in sprout/hooks/names.ts. The evolution thresholds are SECOND_AT and THIRD_AT in sprout/hooks/evolve.ts.
MIT, © 2026 Sgript.
hooks/register.tsx 434 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Group, HubCounts, HubRow } from '../types'
5import {
6 age,
7 arrange,
8 clip,
9 countsOf,
10 focusUrlIn,
11 isAlive,
12 newSessionDir,
13 parseBeacon,
14 parsePs,
15 parseRegistered,
16 readTail,
17 rowFor,
18 statusLine,
19 tabConfig,
20 transcriptPath,
21} from './hub'
22import type { Beacon, Registered, Transcript } from './hub'
23
24const POLL_MS = 3_000
25const LIVENESS_MS = 30_000
26const TAIL_BYTES = '262144'
27// The hub's own pane, a tab of its own beside the other mods' panes.
28const PANE = 'sessions'
29const TITLE = 'Sessions'
30// The Warp Tab Config `/sessions new` rewrites and opens: ~/.warp/tab_configs/<name>.toml.
31const TAB_CONFIG = 'claude_session_hub'
32// Sessions the band above the prompt names, at most.
33const BAND_MAX = 3
34
35const rowsAtom = atom({ plugin: 'session-hub', key: 'rows' } as const, [])
36const countsAtom = atom({ plugin: 'session-hub', key: 'counts' } as const, { needs: 0, turn: 0, working: 0, idle: 0, stale: 0 })
37const showStale = atom({ plugin: 'session-hub', key: 'showStale' } as const, false)
38const minute = atom({ plugin: 'session-hub', key: 'minute' } as const, 0)
39
40// Theme colours, the same urgency scale the Tasks pane uses: error for what
41// needs you now, success for your turn, warning for work under way.
42const GROUP_LOOK: Readonly<Record<Group, { title: string; glyph: string; color: string }>> = {
43 needs: { title: 'NEEDS YOU', glyph: '!', color: 'error' },
44 turn: { title: 'YOUR TURN', glyph: '→', color: 'success' },
45 working: { title: 'WORKING', glyph: '◐', color: 'warning' },
46 idle: { title: 'IDLE', glyph: '·', color: 'inactive' },
47 stale: { title: 'STALE', glyph: '○', color: 'inactive' },
48}
49// Only these get a full card; the rest are one line.
50const isCard = (group: Group) => group === 'needs' || group === 'turn'
51
52// What the poller keeps between ticks. A reload starts these over, and the
53// next poll reads everything again.
54let home = ''
55let thisId = ''
56let me: Beacon = { sessionId: '', focusUrl: '', lastInputAt: 0, lastTurnEndAt: 0 }
57const records = new Map<string, { mtime: number; record: Registered }>()
58const beacons = new Map<string, { mtime: number; beacon: Beacon }>()
59const transcripts = new Map<string, { updatedAt: number; transcript: Transcript }>()
60const lastStatus = new Map<string, string>()
61const seenTurnEnd = new Map<string, number>()
62let started = new Map<number, string>()
63let checkedAt = 0
64let lastRows = ''
65let lastCounts = ''
66let lastLine = ''
67let isPolling = false
68
69const sessionsDir = () => `${home}/.claude/sessions`
70const beaconsDir = () => `${home}/.claude/session-hub/beacons`
71
72/** Learns where the files are and which session this is, once per load. */
73async function ensureReady($: EngineInterface): Promise<void> {
74 if (home) return
75 home = (await $.env.get('HOME')) ?? ''
76 thisId = await $.session.id()
77 me = { ...me, sessionId: thisId, focusUrl: (await $.env.get('WARP_FOCUS_URL')) ?? '' }
78 await writeBeacon($)
79}
80
81async function writeBeacon($: EngineInterface): Promise<void> {
82 if (!home || !me.sessionId) return
83 await $.fs.write(`${beaconsDir()}/${me.sessionId}.json`, JSON.stringify(me)).catch(() => undefined)
84}
85
86/** Reads the registry files that changed since the last poll. */
87async function readRegistry($: EngineInterface): Promise<void> {
88 const entries = await $.fs.list(sessionsDir()).catch(() => [])
89 const files = entries.filter(entry => entry.kind === 'file' && /^\d+\.json$/.test(entry.name))
90 const present = new Set(files.map(file => file.name))
91 for (const name of [...records.keys()]) if (!present.has(name)) records.delete(name)
92 for (const file of files) {
93 if (records.get(file.name)?.mtime === file.mtimeMs) continue
94 const record = parseRegistered(await $.fs.read(`${sessionsDir()}/${file.name}`).catch(() => ''))
95 // A file caught mid-write keeps its last good record and is read again next poll.
96 if (record) records.set(file.name, { mtime: file.mtimeMs, record })
97 }
98}
99
100async function readBeacons($: EngineInterface): Promise<void> {
101 const entries = await $.fs.list(beaconsDir()).catch(() => [])
102 for (const entry of entries) {
103 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
104 const id = entry.name.slice(0, -5)
105 if (beacons.get(id)?.mtime === entry.mtimeMs) continue
106 const beacon = parseBeacon(await $.fs.read(`${beaconsDir()}/${entry.name}`).catch(() => ''))
107 if (beacon) beacons.set(id, { mtime: entry.mtimeMs, beacon })
108 }
109}
110
111/** Which registered processes are still the ones that registered: one `ps` for all. */
112async function checkLiveness($: EngineInterface, now: number): Promise<void> {
113 if (now - checkedAt < LIVENESS_MS && checkedAt > 0) return
114 const pids = [...records.values()].map(({ record }) => record.pid)
115 if (pids.length === 0) return
116 const ran = await $.process
117 .run(['ps', '-o', 'pid=,lstart=', '-p', pids.join(',')], { env: { TZ: 'UTC', LC_ALL: 'C' } })
118 .catch(() => undefined)
119 if (!ran) return
120 started = parsePs(ran.stdout)
121 checkedAt = now
122}
123
124/** The end of a session's transcript, read again only when its registry record changed. */
125async function transcriptOf($: EngineInterface, record: Registered): Promise<Transcript | undefined> {
126 const held = transcripts.get(record.sessionId)
127 if (held && held.updatedAt === record.updatedAt) return held.transcript
128 const ran = await $.process
129 .run(['tail', '-c', TAIL_BYTES, transcriptPath(home, record.cwd, record.sessionId)])
130 .catch(() => undefined)
131 const transcript = readTail(ran?.stdout ?? '')
132 transcripts.set(record.sessionId, { updatedAt: record.updatedAt, transcript })
133
134 return transcript
135}
136
137async function poll($: EngineInterface): Promise<void> {
138 await ensureReady($)
139 if (!home) return
140 const now = await $.clock.now()
141 await readRegistry($)
142 await readBeacons($)
143 await checkLiveness($, now)
144 const seen = ((await $.store.get('seen')) ?? {}) as Record<string, number>
145
146 const rows: HubRow[] = []
147 for (const { record } of records.values()) {
148 if (checkedAt > 0 && !isAlive(started, record)) continue
149 // A turn this hub watched end, for sessions that write no beacon.
150 const before = lastStatus.get(record.sessionId)
151 if (before && before !== 'idle' && record.status === 'idle') seenTurnEnd.set(record.sessionId, record.statusUpdatedAt)
152 lastStatus.set(record.sessionId, record.status)
153 rows.push(
154 rowFor({
155 record,
156 transcript: await transcriptOf($, record),
157 beacon: beacons.get(record.sessionId)?.beacon,
158 seenTurnEnd: seenTurnEnd.get(record.sessionId) ?? 0,
159 seenAt: seen[record.sessionId] ?? 0,
160 thisSessionId: thisId,
161 now,
162 }),
163 )
164 }
165
166 // This session is the one you are in, so it is left out of its own list.
167 const arranged = arrange(rows).filter(row => !row.isThis)
168 const rowsJson = JSON.stringify(arranged)
169 if (rowsJson !== lastRows) {
170 lastRows = rowsJson
171 await update($, rowsAtom, () => arranged)
172 }
173 const counts: HubCounts = countsOf(arranged)
174 const countsJson = JSON.stringify(counts)
175 if (countsJson !== lastCounts) {
176 lastCounts = countsJson
177 await update($, countsAtom, () => counts)
178 }
179 const line = statusLine(counts)
180 if (line !== lastLine) {
181 lastLine = line
182 $.ui.status(line || undefined)
183 }
184 const thisMinute = Math.floor(now / 60_000)
185 if ((await read($, minute)) !== thisMinute) await update($, minute, () => thisMinute)
186}
187
188function pollSoon($: EngineInterface): Promise<void> {
189 if (isPolling) return Promise.resolve()
190 isPolling = true
191 return poll($)
192 .catch(error => $.ui.log(`session-hub: poll failed: ${String(error)}`, { to: 'debug' }))
193 .finally(() => {
194 isPolling = false
195 })
196}
197
198/**
199 * Jumps to a session's Warp pane. Its beacon has the link; for a session that
200 * started before the hub, the link is read from that one process's environment
201 * when you press, and nothing else from it is kept. Failing that, it says where
202 * the session is.
203 */
204async function jump($: EngineInterface, row: HubRow): Promise<void> {
205 const seen = ((await $.store.get('seen')) ?? {}) as Record<string, number>
206 seen[row.sessionId] = await $.clock.now()
207 await $.store.set('seen', seen)
208
209 let url = row.focusUrl
210 if (!url && !row.isDesktop) {
211 const ran = await $.process.run(['ps', '-E', '-p', String(row.pid), '-o', 'command=']).catch(() => undefined)
212 url = focusUrlIn(ran?.stdout ?? '')
213 }
214 const opened = url ? await $.process.run(['open', url]).catch(() => undefined) : undefined
215 if (!opened || opened.exitCode !== 0) {
216 $.ui.toast(
217 row.isDesktop ? `"${row.title}" is in Claude Desktop.` : `No jump link for "${row.title}": it is in ${row.cwd}.`,
218 )
219 }
220 await pollSoon($)
221}
222
223/**
224 * Opens a new Warp tab running `claude` in `args` (a path, `~` allowed), or
225 * this session's directory. Warp only runs a command in a new tab from a Tab
226 * Config, so the hub rewrites its own one and opens it by name.
227 */
228async function startNew($: EngineInterface, args: string): Promise<string> {
229 await ensureReady($)
230 const dir = newSessionDir(args, home, await $.session.cwd())
231 await $.fs.write(`${home}/.warp/tab_configs/${TAB_CONFIG}.toml`, tabConfig(dir))
232 const opened = await $.process.run(['open', `warp://tab_config/${TAB_CONFIG}`]).catch(() => undefined)
233 if (!opened || opened.exitCode !== 0) {
234 const said = `Couldn't open Warp for a new session in ${dir}.`
235 $.ui.toast(said)
236 return said
237 }
238
239 return `New Claude session opening in Warp, in ${dir}.`
240}
241
242const listedOf = async ($: EngineInterface): Promise<HubRow[]> => {
243 const rows = await read($, rowsAtom)
244 return (await read($, showStale)) ? rows : rows.filter(row => row.group !== 'stale')
245}
246
247export const register: Register = on => {
248 on('session.start', async ($, e, next) => {
249 const started = await next(e)
250 await ensureReady($)
251 await $.command.register({
252 name: 'sessions',
253 description: 'Focus the sessions pane, jump to one (/sessions 3), or start one (/sessions new [dir])',
254 argumentHint: '[number | new [dir]]',
255 })
256 void pollSoon($)
257 $.clock.every(POLL_MS, () => void pollSoon($))
258 void $.ui.open({ id: PANE, title: TITLE })
259
260 return started
261 })
262
263 // `/sessions` opens the pane with the keyboard on the first session, so
264 // 1-9 or Tab and Enter work at once; `/sessions 3` jumps straight to the third.
265 on('command.run', { command: 'sessions' }, async ($, e) => {
266 const asked = /^new\b\s*(.*)$/.exec(e.args.trim())
267 if (asked) return { text: await startNew($, asked[1] ?? '') }
268 await pollSoon($)
269 const listed = await listedOf($)
270 const picked = /^\d+$/.test(e.args.trim()) ? listed[Number(e.args.trim()) - 1] : undefined
271 if (picked) {
272 await jump($, picked)
273 return { text: `Jumped to ${picked.title}.` }
274 }
275
276 await $.ui.open({ id: PANE, title: TITLE, focus: true }).catch(() => undefined)
277 const first = listed[0]
278 if (first) void $.ui.focus({ requestId: PANE, key: `session-${first.sessionId}` }).catch(() => undefined)
279 const counts = await read($, countsAtom)
280
281 return { text: statusLine(counts) || 'No other sessions are open.' }
282 })
283
284 // This session's beacon: when you last typed here, and when its last turn ended.
285 on('prompt.submit', async ($, e, next) => {
286 await ensureReady($)
287 me = { ...me, lastInputAt: await $.clock.now() }
288 void writeBeacon($)
289
290 return next(e)
291 }).catch(($, e, next) => next(e))
292
293 on('turn.complete', async ($, e, next) => {
294 const done = await next(e)
295 if ((e as { agentId?: unknown }).agentId === undefined) {
296 await ensureReady($)
297 me = { ...me, lastTurnEndAt: await $.clock.now() }
298 void writeBeacon($)
299 }
300
301 return done
302 })
303
304 // 'sessions' is PANE, written out so `claude plugin validate` can read the matcher.
305 // Sessions that need you or have answered get a card; the rest are one line.
306 // The pane scrolls, so nothing is cut to fit.
307 on('ui.render', { component: 'Pane', requestId: 'sessions' }, async ($, e) => {
308 const { Box, Text, Button } = $.ui.resolve(e)
309 const rows = await read($, rowsAtom)
310 const isStaleShown = await read($, showStale)
311 await read($, minute)
312 const now = await $.clock.now()
313 const width = Math.max(30, e.props.bodyColumns)
314
315 const shown = rows.filter(row => row.group !== 'stale' || isStaleShown)
316 const staleCount = rows.filter(row => row.group === 'stale').length
317 const lines: RenderChildren[] = []
318 let group: Group | undefined
319 shown.forEach((row, index) => {
320 const look = GROUP_LOOK[row.group]
321 const hasCard = isCard(row.group)
322 if (row.group !== group) {
323 if (group !== undefined) lines.push(<Text> </Text>)
324 group = row.group
325 lines.push(
326 <Text bold color={look.color}>
327 {look.title}
328 </Text>,
329 )
330 }
331 const when = age(now - row.since)
332 const place = hasCard ? '' : row.isDesktop ? 'Desktop' : row.project
333 const room = Math.max(8, width - when.length - 6 - (place ? place.length + 3 : 0))
334 lines.push(
335 <Box flexDirection="row" justifyContent="space-between">
336 <Button
337 key={`session-${row.sessionId}`}
338 plain
339 hotkey={index < 9 ? String(index + 1) : undefined}
340 dimColor={row.group === 'stale'}
341 onPress={() => jump($, row)}
342 >
343 <Text color={look.color}>{look.glyph}</Text> <Text bold={hasCard}>{clip(row.title, room)}</Text>
344 {place !== '' && <Text dimColor> · {place}</Text>}
345 </Button>
346 <Text dimColor>{when}</Text>
347 </Box>,
348 )
349 const pad = 5
350 if (hasCard) {
351 const where = [row.isDesktop ? 'Desktop' : row.project, row.branch, row.engine].filter(Boolean).join(' · ')
352 lines.push(
353 <Box flexDirection="column" paddingLeft={pad}>
354 <Text dimColor wrap="truncate-end">
355 {where}
356 </Text>
357 {row.reason !== '' && (
358 <Text color={look.color} wrap="wrap">
359 {clip(row.reason, (width - pad) * 2)}
360 </Text>
361 )}
362 {row.prompt !== '' && (
363 <Text dimColor wrap="truncate-end">
364 you: {row.prompt}
365 </Text>
366 )}
367 </Box>,
368 )
369 } else if (row.group === 'working' && row.reason !== 'working') {
370 // What a working session last said, so you can tell them apart.
371 lines.push(
372 <Box paddingLeft={pad}>
373 <Text dimColor wrap="truncate-end">
374 {row.reason}
375 </Text>
376 </Box>,
377 )
378 }
379 })
380 if (staleCount > 0) {
381 lines.push(
382 <Button key="sessions-stale" plain dimColor onPress={() => update($, showStale, isNow => !isNow)}>
383 {isStaleShown ? `hide ${staleCount} stale` : `+ ${staleCount} stale (idle over 3 days)`}
384 </Button>,
385 )
386 }
387 if (rows.length === 0) lines.push(<Text dimColor>No other sessions open.</Text>)
388
389 return (
390 <Box flexDirection="column">
391 {lines}
392 <Text> </Text>
393 <Box flexDirection="row" gap={2}>
394 <Button key="sessions-new" plain hotkey="n" onPress={() => void startNew($, '')}>
395 New session here
396 </Button>
397 <Text dimColor>1-9 jump · /sessions new [dir]</Text>
398 </Box>
399 </Box>
400 )
401 })
402
403 // A line above the prompt naming the sessions that need you or have
404 // answered, each one press from a jump: click it, or ctrl+x tab then Tab and
405 // Enter. Digit hotkeys are left off: a bare digit in an empty prompt would
406 // press them. It sits above whatever another plugin draws in the band.
407 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
408 if (e.props.hasSurvey) return next(e)
409 const waiting = (await read($, rowsAtom)).filter(row => isCard(row.group))
410 if (waiting.length === 0) return next(e)
411 const { Box, Text, Button } = $.ui.resolve(e)
412 const each = Math.max(10, Math.floor((e.props.bodyColumns - 12) / Math.min(BAND_MAX, waiting.length)) - 5)
413 const mine = (
414 <Box flexDirection="row" gap={2}>
415 <Text dimColor>sessions</Text>
416 {waiting.slice(0, BAND_MAX).map(row => (
417 <Button key={`band-${row.sessionId}`} plain onPress={() => jump($, row)}>
418 <Text color={GROUP_LOOK[row.group].color}>{GROUP_LOOK[row.group].glyph}</Text> {clip(row.title, each)}
419 </Button>
420 ))}
421 {waiting.length > BAND_MAX && <Text dimColor>+{waiting.length - BAND_MAX}</Text>}
422 </Box>
423 )
424 const below = await next(e)
425
426 return below.type === 'engine' ? mine : (
427 <Box flexDirection="column">
428 {mine}
429 {below}
430 </Box>
431 )
432 })
433}
434hooks/hub.ts 316 lines1import type { Group, HubCounts, HubRow } from '../types'
2
3// The session hub's logic, kept free of `$` so it can be tested: reading the
4// engine's session registry (~/.claude/sessions/<pid>.json), reading the end of
5// a session's transcript, and placing each session in the queue.
6
7export const STALE_MS = 3 * 24 * 60 * 60 * 1000
8
9/** A registry record: one session the engine says is open. */
10export type Registered = {
11 pid: number
12 sessionId: string
13 cwd: string
14 name: string
15 nameSource: string
16 kind: string
17 entrypoint: string
18 status: string
19 waitingFor: string | null
20 statusUpdatedAt: number
21 updatedAt: number
22 procStart: string
23}
24
25/** What a session writes about itself for the hubs in other sessions to read. */
26export type Beacon = { sessionId: string; focusUrl: string; lastInputAt: number; lastTurnEndAt: number }
27
28const text = (value: unknown) => (typeof value === 'string' ? value : '')
29const count = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
30
31/**
32 * A registry file's record, or null for one that is not an open session: a
33 * pre-warmed spare, a parked job, a daemon, or a file read halfway through a
34 * write (which the next poll reads again).
35 */
36export function parseRegistered(json: string): Registered | null {
37 let raw: Record<string, unknown>
38 try {
39 raw = JSON.parse(json) as Record<string, unknown>
40 } catch {
41 return null
42 }
43 const kind = text(raw.kind)
44 if (kind !== 'interactive' && kind !== 'bg') return null
45 if (raw.spare === true || raw.parkedJobId) return null
46 const sessionId = text(raw.sessionId)
47 const pid = count(raw.pid)
48 if (!sessionId || pid <= 0) return null
49
50 return {
51 pid,
52 sessionId,
53 cwd: text(raw.cwd),
54 name: text(raw.name),
55 nameSource: text(raw.nameSource),
56 kind,
57 entrypoint: text(raw.entrypoint),
58 status: text(raw.status),
59 waitingFor: typeof raw.waitingFor === 'string' ? raw.waitingFor : null,
60 statusUpdatedAt: count(raw.statusUpdatedAt) || count(raw.updatedAt),
61 updatedAt: count(raw.updatedAt),
62 procStart: text(raw.procStart),
63 }
64}
65
66export function parseBeacon(json: string): Beacon | null {
67 try {
68 const raw = JSON.parse(json) as Record<string, unknown>
69 const sessionId = text(raw.sessionId)
70 if (!sessionId) return null
71 return { sessionId, focusUrl: text(raw.focusUrl), lastInputAt: count(raw.lastInputAt), lastTurnEndAt: count(raw.lastTurnEndAt) }
72 } catch {
73 return null
74 }
75}
76
77/** Where the engine keeps a session's transcript. */
78export function transcriptPath(home: string, cwd: string, sessionId: string): string {
79 return `${home}/.claude/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${sessionId}.jsonl`
80}
81
82export type Transcript = {
83 customTitle: string
84 aiTitle: string
85 branch: string
86 model: string
87 effort: string
88 recap: string
89 lastReply: string
90 lastPrompt: string
91}
92
93const EMPTY: Transcript = { customTitle: '', aiTitle: '', branch: '', model: '', effort: '', recap: '', lastReply: '', lastPrompt: '' }
94
95/**
96 * What the end of a transcript says, the newest record of each kind winning.
97 * The first line of a cut tail is a fragment and parses to nothing.
98 */
99export function readTail(tail: string): Transcript {
100 const found = { ...EMPTY }
101 for (const line of tail.split('\n')) {
102 if (!line.startsWith('{')) continue
103 let record: Record<string, unknown>
104 try {
105 record = JSON.parse(line) as Record<string, unknown>
106 } catch {
107 continue
108 }
109 switch (record.type) {
110 case 'custom-title':
111 found.customTitle = text(record.customTitle) || found.customTitle
112 break
113 case 'ai-title':
114 found.aiTitle = text(record.aiTitle) || found.aiTitle
115 break
116 case 'last-prompt':
117 found.lastPrompt = text(record.lastPrompt) || found.lastPrompt
118 break
119 case 'system':
120 if (record.subtype === 'away_summary') found.recap = text(record.content) || found.recap
121 break
122 case 'assistant': {
123 const message = (record.message ?? {}) as { model?: unknown; content?: unknown }
124 found.branch = text(record.gitBranch) || found.branch
125 found.model = text(message.model) || found.model
126 found.effort = text(record.effort) || found.effort
127 const blocks = Array.isArray(message.content) ? (message.content as { type?: unknown; text?: unknown }[]) : []
128 const said = blocks
129 .filter(block => block.type === 'text')
130 .map(block => text(block.text))
131 .join(' ')
132 .trim()
133 if (said) found.lastReply = said
134 break
135 }
136 }
137 }
138
139 return found
140}
141
142/** `ps -o pid=,lstart=` output, as pid to start time with its spacing evened out. */
143export function parsePs(stdout: string): Map<number, string> {
144 const started = new Map<number, string>()
145 for (const line of stdout.split('\n')) {
146 const found = /^\s*(\d+)\s+(.+?)\s*$/.exec(line)
147 if (found) started.set(Number(found[1]), (found[2] ?? '').replace(/\s+/g, ' '))
148 }
149
150 return started
151}
152
153/** A record whose process is gone, or whose pid now belongs to a newer process, is not open. */
154export function isAlive(started: ReadonlyMap<number, string>, record: Registered): boolean {
155 const start = started.get(record.pid)
156 if (start === undefined) return false
157
158 return !record.procStart || start === record.procStart.replace(/\s+/g, ' ').trim()
159}
160
161export function modelLabel(id: string): string {
162 const found = /(opus|sonnet|haiku|fable)[- ]?(\d+)(?:[-.](\d+))?/i.exec(id)
163 if (!found) return id
164 const [, family = '', major = '', minor] = found
165
166 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? `.${minor}` : ''}`
167}
168
169/** What a session is waiting for, as the engine names it, in one word. */
170const WAITING: Readonly<Record<string, string>> = {
171 'permission prompt': 'permission',
172 'sandbox request': 'permission',
173 'worker request': 'permission',
174 'goal proposal': 'permission',
175 'input needed': 'question',
176 'dialog open': 'dialog open',
177}
178
179export type RowInputs = {
180 record: Registered
181 transcript: Transcript | undefined
182 beacon: Beacon | undefined
183 /** When this hub saw the session go from working to idle, for one with no beacon. */
184 seenTurnEnd: number
185 /** When you last jumped to it or marked it seen. */
186 seenAt: number
187 thisSessionId: string
188 now: number
189}
190
191/**
192 * A session's row. Needs you: the engine says it is waiting. Your turn: it
193 * finished a turn after you last typed there and you have not looked since.
194 * Working, idle, and stale after three idle days.
195 */
196export function rowFor(inputs: RowInputs): HubRow {
197 const { record, beacon, now } = inputs
198 const said = inputs.transcript ?? EMPTY
199 const project = record.cwd.split('/').filter(Boolean).pop() ?? record.cwd
200 const title = said.customTitle || (record.nameSource === 'user' ? record.name : '') || said.aiTitle || record.name || project
201 const isThis = record.sessionId === inputs.thisSessionId
202 const turnEnd = beacon?.lastTurnEndAt || inputs.seenTurnEnd
203 const isYourTurn = !isThis && turnEnd > 0 && turnEnd > Math.max(beacon?.lastInputAt ?? 0, inputs.seenAt)
204 const engine = [modelLabel(said.model), said.effort].filter(Boolean).join(' · ')
205
206 let group: Group
207 let reason: string
208 if (record.status === 'waiting') {
209 group = 'needs'
210 reason = WAITING[record.waitingFor ?? ''] ?? 'needs you'
211 } else if (record.status === 'busy') {
212 group = 'working'
213 // What it last said is what it is doing; the group already says "working".
214 reason = said.lastReply || 'working'
215 } else if (record.status === 'shell') {
216 group = 'working'
217 reason = 'jobs running'
218 } else if (isYourTurn) {
219 group = 'turn'
220 reason = said.lastReply ? `"${said.lastReply}"` : 'finished'
221 } else {
222 group = now - record.statusUpdatedAt > STALE_MS ? 'stale' : 'idle'
223 reason = said.recap || 'idle'
224 }
225
226 return {
227 sessionId: record.sessionId,
228 title,
229 project,
230 branch: said.branch,
231 group,
232 reason: reason.replace(/\s+/g, ' ').trim(),
233 engine,
234 prompt: said.lastPrompt.replace(/\s+/g, ' ').trim(),
235 since: record.statusUpdatedAt,
236 isThis,
237 isDesktop: record.entrypoint !== 'cli',
238 cwd: record.cwd,
239 pid: record.pid,
240 focusUrl: beacon?.focusUrl ?? '',
241 }
242}
243
244export const GROUPS: readonly Group[] = ['needs', 'turn', 'working', 'idle', 'stale']
245
246/** Rows in queue order: the longest waiting first among those that need you, else the newest first. */
247export function arrange(rows: readonly HubRow[]): HubRow[] {
248 return [...rows].sort((a, b) => {
249 const byGroup = GROUPS.indexOf(a.group) - GROUPS.indexOf(b.group)
250 if (byGroup !== 0) return byGroup
251 return a.group === 'needs' ? a.since - b.since : b.since - a.since
252 })
253}
254
255export function countsOf(rows: readonly HubRow[]): HubCounts {
256 const counts: HubCounts = { needs: 0, turn: 0, working: 0, idle: 0, stale: 0 }
257 for (const row of rows) if (!row.isThis) counts[row.group] += 1
258
259 return counts
260}
261
262/** `45s`, `12m`, `3h`, `4d`. */
263export function age(ms: number): string {
264 const seconds = Math.max(0, Math.round(ms / 1000))
265 if (seconds < 60) return `${seconds}s`
266 if (seconds < 3600) return `${Math.round(seconds / 60)}m`
267 if (seconds < 86400) return `${Math.round(seconds / 3600)}h`
268
269 return `${Math.round(seconds / 86400)}d`
270}
271
272export function clip(value: string, max: number): string {
273 if (max <= 0) return ''
274 return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1))}…`
275}
276
277/** The one-line summary under the prompt, or '' with nothing else open. */
278export function statusLine(counts: HubCounts): string {
279 const parts = [
280 counts.needs && `${counts.needs} need${counts.needs === 1 ? 's' : ''} you`,
281 counts.turn && `${counts.turn} your turn`,
282 counts.working && `${counts.working} working`,
283 counts.idle && `${counts.idle} idle`,
284 ].filter(Boolean)
285
286 return parts.length ? `sessions · ${parts.join(' · ')}` : ''
287}
288
289/** Where `/sessions new` starts: the path given (`~` expanded), else `fallback`. */
290export function newSessionDir(args: string, home: string, fallback: string): string {
291 const given = args.trim()
292 if (!given) return fallback
293 return given === '~' || given.startsWith('~/') ? `${home}${given.slice(1)}` : given
294}
295
296/** The Warp Tab Config the hub rewrites for each new session: one pane running `claude` in `dir`. */
297export function tabConfig(dir: string): string {
298 return [
299 'name = "Claude session (session-hub)"',
300 'title = "claude"',
301 '',
302 '[[panes]]',
303 'id = "claude"',
304 'type = "terminal"',
305 `directory = ${JSON.stringify(dir)}`,
306 'commands = ["claude"]',
307 'is_focused = true',
308 '',
309 ].join('\n')
310}
311
312/** The Warp jump link in a process's environment, nothing else kept. */
313export function focusUrlIn(environment: string): string {
314 return /WARP_FOCUS_URL=(warp:\/\/session\/[0-9a-fA-F-]+)/.exec(environment)?.[1] ?? ''
315}
316types/index.d.ts 43 lines1/** Where a session sits in the queue, most urgent first. */
2export type Group = 'needs' | 'turn' | 'working' | 'idle' | 'stale'
3
4/** One open session, as the Sessions section shows it. */
5export type HubRow = {
6 sessionId: string
7 /** Its title: a custom title, a name you gave it, its AI title, else the derived name. */
8 title: string
9 /** The last folder of its working directory. */
10 project: string
11 branch: string
12 group: Group
13 /** Why it is in its group: "permission", "question", its last reply, its recap, "working". */
14 reason: string
15 /** Its model and effort, as `Opus 5.5 · high`, or ''. */
16 engine: string
17 /** The last prompt you gave it, or ''. */
18 prompt: string
19 /** When it entered its current state, ms since the epoch. */
20 since: number
21 isThis: boolean
22 isDesktop: boolean
23 cwd: string
24 pid: number
25 /** Its Warp jump link from its beacon, or '' when it has none yet. */
26 focusUrl: string
27}
28
29/** The sessions in each group, this session left out: what Sprout and the status line read. */
30export type HubCounts = { needs: number; turn: number; working: number; idle: number; stale: number }
31
32declare module 'claude-code' {
33 interface PluginState {
34 'session-hub': {
35 rows: HubRow[]
36 counts: HubCounts
37 showStale: boolean
38 /** The minute, so ages redraw while nothing else changes. */
39 minute: number
40 }
41 }
42}
43