Dashboard of running Claude sessions: context, active/idle, subagents, workflows and who messages whom

Two Claude Code mods (plugins of function hooks) in one local marketplace, local-mods:
| Mod | Command | What it does |
|---|---|---|
agent-fleet | /fleet | A pane with every running session that has the mod: context fill, working/idle, subagents and workflow agents, who messages whom, cost, and an autopilot badge. /fleet toggles it. |
autopilot | /autopilot | Runs a session on its own for a set time with a fixed role. At a context threshold it writes a handoff, clears itself and resumes from the handoff, instead of auto-compacting. Respects the permission mode, defers blocked actions, watches token limits, works with /goal. |
Function-hook mods are early access: you need a recent Claude Code (built and tested on 2.1.295).
Install a signed release tag, not main: pick the latest tag from the releases and put it after #.
claude plugin marketplace add VladLeus/claude-mods#v0.2.0
claude plugin install autopilot@local-mods
claude plugin install agent-fleet@local-mods
Restart Claude Code. The marketplace stays on that tag, and third-party marketplaces do not auto-update unless you turn it on in /plugin → Marketplaces. To move to a newer release, check its notes and diff first, then remove the marketplace and add it again with the new tag.
This mode is for developing the mods, not for everyday use. The marketplace is read live from your clone, so any commit you pull or check out runs with full access to your machine on the next reload. Review untrusted PRs in a separate worktree or clone that no marketplace points at. Never run claude plugin test (or reload plugins) on an untrusted PR checkout locally: spec files and hooks are code that runs with your full user access, so let the specs CI run them.
git clone https://github.com/VladLeus/claude-mods.git ~/code/claude-mods
claude plugin marketplace add ~/code/claude-mods
claude plugin install autopilot@local-mods --scope user
claude plugin install agent-fleet@local-mods --scope user
A marketplace added from a folder is read from that folder: edit the files, then run /reload-plugins in a session.
Autopilot hands off with two skills. Copy them unless you already have skills with these names:
cp -R extras/skills/create-handoff-doc extras/skills/resume-handoff-doc ~/.claude/skills/
Handoffs are written to thoughts/shared/handoffs/ of the project.
/autopilot <time> <threshold%> [max restarts] "<role>" [--goal "<condition>"] [--5h 95] [--week 80]
/autopilot # status
/autopilot 0 0 stop # hand the wheel back (also clears the goal)
Example, a night run:
/autopilot 10h 65 "You are the docs writer, responsible for … Use /explore for the map." --goal "issue #42 is closed and its docs are merged" --5h 95 --week 80
90m, 2h, 1h30m, or minutes.autoCompactWindow in ~/.claude/settings.json (800000 on a 1M window gives ~66%). Raise that setting to allow a higher threshold./clear, and goes into every handoff with the session's /rename name and /color (the color is set again after each /clear). /skills named in the role are recognised and announced to the model as skills./goal completion condition, set again after every /clear. When the evaluator marks it met, autopilot asks for a final handoff and stops. Phrase it in terms of files or repo state, not "in the chat": after /clear the chat is empty.While it runs:
AskUserQuestion) are answered with "decide yourself within your role"; the decision goes into the handoff. Only a question that truly cannot wait is sent to your phone (PushNotification).AUTOPILOT_DONE and autopilot wraps up.~/.claude/autopilot/<session id>.log (decisions, deferrals, restarts, limits)./fleet toggles the pane (/fleet open, /fleet close). Every session with the mod writes a heartbeat to ~/.claude/fleet/<session id>.json every 15 s and on every turn, spawn and message; the pane reads that folder. Ended and cleared sessions are hidden at once (a resume brings them back), a silent one shows "no signal" after 3 minutes and is dropped after an hour, and files older than a day are deleted.
main takes changes through pull requests only, and a PR merges after the owner (@VladLeus) approves it:
main.owner-approval, specs (each mod and the marketplace) and signed-commits. Commits must be signed.protected-paths is a warning, not a requirement: it turns red when a PR touches .github/, CODEOWNERS or removes or shrinks tests, so the owner reviews those changes with extra care.owner-approval status is success only when the owner's latest review is an approval of the PR's current head; the owner-approved label mirrors it and a label set by hand is overwritten.main, not even for admins: an admin can bypass the rules only by merging a PR.Collaborators push branches to this repository. A PR from a fork works too, but its label and status are then set by the owner by hand (a fork's workflow token cannot write to this repository).
/reload-plugins in a session. Check with: ``bash claude plugin validate autopilot claude plugin test autopilot ``version in .claude-plugin/plugin.json and run claude plugin update <mod>@local-mods after a change. /reload-plugins reads the folder, but a restarted Claude Code (the desktop app especially) loads the copy cached at the last install or update..claude-plugin/types/ (git-ignored); grep claude-code/index.d.ts there for events and $ methods.fleet.ts, autopilot.ts); register.tsx holds the hooks.hooks/register.tsx 517 lines1import { atom, read, update } from 'claude-code'
2import type { AgentStatus, EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, AgentRunState, AutopilotBadge, Beat, Liveness } from '../types'
5import {
6 BEAT_MS,
7 CLEANUP_MINUTES,
8 SESSION_COLORS,
9 agentRows,
10 ago,
11 autopilotLine,
12 bar,
13 basename,
14 beatFile,
15 clean,
16 displayName,
17 identityFromRows,
18 liveness,
19 messageLines,
20 shortId,
21 shortModel,
22 tokensK,
23 upsertLink,
24 validBadge,
25 validBeat,
26 visible,
27 MAX_BEAT_BYTES,
28} from './fleet'
29
30const PANE = 'agent-fleet'
31const fleet = atom({ plugin: 'agent-fleet', key: 'fleet' } as const, [] as Beat[])
32const selfId = atom({ plugin: 'agent-fleet', key: 'selfId' } as const, '')
33
34const STATE_PILL: Record<Liveness, { glyph: string; word: string; color: string }> = {
35 active: { glyph: '●', word: 'working', color: 'success' },
36 idle: { glyph: '○', word: 'idle', color: 'subtle' },
37 stale: { glyph: '◌', word: 'no signal', color: 'warning' },
38 ended: { glyph: '✕', word: 'ended', color: 'inactive' },
39}
40const AGENT_GLYPH: Record<AgentRunState, { glyph: string; color: string }> = {
41 running: { glyph: '▸', color: 'success' },
42 waiting: { glyph: '…', color: 'warning' },
43 done: { glyph: '✓', color: 'subtle' },
44 failed: { glyph: '✗', color: 'error' },
45}
46const RUN_STATE: Record<AgentStatus, AgentRunState> = {
47 pending: 'running',
48 running: 'running',
49 waiting: 'waiting',
50 idle: 'waiting',
51 completed: 'done',
52 failed: 'failed',
53 killed: 'failed',
54}
55
56let me: Beat | null = null
57let dir = ''
58let transcript = ''
59let writing: Promise<void> = Promise.resolve()
60let isPaneOpen = false
61let isDirReady = false
62
63function fileOf(id: string): string {
64 return `${dir}/${beatFile(id)}`
65}
66
67function setAgent(id: string, change: (row: AgentRow) => AgentRow): void {
68 if (!me) return
69 me.agents = me.agents.map(row => (row.id === id ? change(row) : row))
70}
71
72/** Reads the session's /rename name and /color from its transcript. */
73async function refreshIdentity($: EngineInterface): Promise<void> {
74 if (!me || !transcript) return
75 let out = ''
76 try {
77 const grep = $.process.spawn({
78 argv: ['/usr/bin/grep', '-E', '^\\{"type":"(custom-title|agent-name|ai-title|agent-color)"', '--', transcript],
79 })
80 for await (const chunk of grep) if (chunk.stream === 'stdout') out += chunk.text
81 } catch {
82 return
83 }
84 const identity = identityFromRows(out.split('\n'))
85 me.name = identity.name === null ? null : clean(identity.name)
86 me.color = identity.color
87}
88
89async function refreshCounts($: EngineInterface): Promise<void> {
90 if (!me) return
91 const [usage, agents, now] = await Promise.all([
92 $.session.usage().catch(() => null),
93 $.agent.list().catch(() => []),
94 $.clock.now(),
95 ])
96 if (usage) {
97 me.context = {
98 percent: usage.context.percent ?? null,
99 tokens: usage.context.tokens ?? null,
100 window: usage.context.window,
101 }
102 me.costUsd = usage.cost?.usd ?? null
103 }
104 for (const info of agents) {
105 const status = RUN_STATE[info.status]
106 const isOver = status === 'done' || status === 'failed'
107 if (!me.agents.some(row => row.id === info.id)) {
108 me.agents.push({
109 id: info.id,
110 label: clean(info.description || info.name || info.type),
111 type: clean(info.type),
112 model: null,
113 status,
114 startedAt: now,
115 endedAt: isOver ? now : null,
116 isWorkflow: false,
117 })
118 continue
119 }
120 setAgent(info.id, row => ({ ...row, status, endedAt: isOver ? (row.endedAt ?? now) : null }))
121 }
122}
123
124/** Writes this session's beat; writes are chained so two never interleave. */
125function beat($: EngineInterface): Promise<void> {
126 writing = writing
127 .then(async () => {
128 if (!me || !dir) return
129 await refreshCounts($)
130 me.beatAt = await $.clock.now()
131 await $.fs.write(fileOf(me.id), JSON.stringify(me))
132 })
133 .catch(() => undefined)
134
135 return writing
136}
137
138/** The autopilot mod's status for a session, when it runs there; null otherwise. */
139async function readAutopilot($: EngineInterface, id: string): Promise<AutopilotBadge | null> {
140 // `id` has passed validBeat: a plain file-safe name equal to its beat file's stem.
141 try {
142 return validBadge(JSON.parse(await $.fs.read(`${dir.replace(/\/fleet$/, '/autopilot')}/${id}.json`)))
143 } catch {
144 return null
145 }
146}
147
148async function scan($: EngineInterface): Promise<void> {
149 if (!dir) return
150 const entries = await $.fs.list(dir).catch(() => [])
151 const byId = new Map<string, Beat>()
152 for (const entry of entries) {
153 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
154 if (entry.size > MAX_BEAT_BYTES) continue
155 try {
156 const valid = validBeat(JSON.parse(await $.fs.read(`${dir}/${entry.name}`)), entry.name)
157 if (!valid) continue
158 // Two files cannot share an id (the id is the stem); if one ever does, the newest beat wins.
159 const seen = byId.get(valid.id)
160 if (seen && seen.beatAt >= valid.beatAt) continue
161 valid.autopilot = await readAutopilot($, valid.id)
162 byId.set(valid.id, valid)
163 } catch {
164 // A file mid-write or foreign: skip it this round.
165 }
166 }
167 await update($, fleet, () => [...byId.values()])
168}
169
170/**
171 * Makes `me` the beat of the session id the engine answers now. /clear (no
172 * session.start follows it) and /resume change the id under a running module:
173 * the old beat is closed and the new id's is started, or picked back up from
174 * its file, so a resumed session keeps its agents and links.
175 */
176async function adopt($: EngineInterface): Promise<void> {
177 const id = await $.session.id()
178 if (me && me.id === id) return
179
180 const now = await $.clock.now()
181 if (me && !me.isEnded) {
182 me.isEnded = true
183 me.isActive = false
184 me.endedAt = now
185 me.endReason = me.endReason ?? 'switched'
186 await beat($)
187 }
188
189 const home = await $.env.get('HOME')
190 if (!home || !home.startsWith('/')) {
191 // No usable home: write and scan nothing.
192 dir = ''
193 transcript = ''
194 return
195 }
196 dir = `${home}/.claude/fleet`
197 if (!isDirReady) {
198 isDirReady = true
199 try {
200 await $.process.run(['/bin/mkdir', '-p', dir])
201 await $.process.run(['/bin/chmod', '700', dir])
202 } catch {
203 // Best effort: the directory is then created on the first write.
204 }
205 }
206 // The project root, not the current directory: a shell `cd` moves only the latter,
207 // and the transcript lives under the root's folder.
208 const [cwd, model, usage] = await Promise.all([
209 $.session.root(),
210 $.session.model(),
211 $.session.usage().catch(() => null),
212 ])
213 transcript = `${home}/.claude/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
214 await update($, selfId, () => id)
215
216 // A hot reload or a /resume: pick that session's agents and links back up.
217 let previous: Partial<Beat> | null = null
218 try {
219 previous = validBeat(JSON.parse(await $.fs.read(fileOf(id))), fileOf(id).slice(dir.length + 1))
220 } catch {
221 previous = null
222 }
223 me = {
224 id,
225 name: null,
226 color: null,
227 label: clean(`${basename(cwd)}·${shortId(id)}`),
228 cwd,
229 model,
230 startedAt: usage?.startedAt ?? now,
231 beatAt: 0,
232 isActive: false,
233 turnStartedAt: null,
234 idleSince: now,
235 isEnded: false,
236 endedAt: null,
237 endReason: null,
238 context: { percent: null, tokens: null, window: 0 },
239 costUsd: null,
240 agents: Array.isArray(previous?.agents) ? previous.agents : [],
241 links: Array.isArray(previous?.links) ? previous.links : [],
242 inboundUnknown: previous?.inboundUnknown ?? 0,
243 }
244 await refreshIdentity($)
245}
246
247/** Deletes heartbeat files nobody has written for CLEANUP_MINUTES. */
248async function cleanup($: EngineInterface): Promise<void> {
249 if (!dir) return
250 try {
251 const find = $.process.spawn({
252 argv: ['/usr/bin/find', dir, '-maxdepth', '1', '-type', 'f', '-name', '*.json', '-mmin', `+${CLEANUP_MINUTES}`, '-delete'],
253 })
254 for await (const _ of find) {
255 // Nothing to read: find prints nothing with -delete.
256 }
257 } catch {
258 // The next sweep tries again.
259 }
260}
261
262async function tick($: EngineInterface): Promise<void> {
263 await adopt($)
264 await beat($)
265 await scan($)
266}
267
268export const register: Register = on => {
269 on('session.start', async ($, e, next) => {
270 await adopt($)
271 void cleanup($)
272
273 await $.command.register({
274 name: 'fleet',
275 description: 'Toggle the agent fleet dashboard (or /fleet open, /fleet close)',
276 })
277 $.clock.every(BEAT_MS, () => void tick($))
278 $.clock.every(60 * 60_000, () => void cleanup($))
279 void tick($)
280
281 return next(e)
282 })
283
284 on('command.run', { command: 'fleet' }, async ($, e) => {
285 const wants = e.args.trim()
286 if (wants === 'close' || (wants !== 'open' && isPaneOpen)) {
287 await $.ui.close({ id: PANE })
288 isPaneOpen = false
289
290 return { text: 'Agent fleet pane closed.' }
291 }
292 await refreshIdentity($)
293 await tick($)
294 await $.ui.open({ id: PANE, title: 'Agent fleet' })
295 isPaneOpen = true
296
297 return { text: 'Agent fleet pane opened. Run /fleet again to close it.' }
298 })
299
300 on('ui.close', async ($, e, next) => {
301 if (e.id === PANE) isPaneOpen = false
302
303 return next(e)
304 })
305
306 on('turn.start', async ($, e, next) => {
307 if (me) {
308 me.isActive = true
309 me.turnStartedAt = await $.clock.now()
310 await refreshIdentity($)
311 void tick($)
312 }
313
314 return next(e)
315 })
316
317 on('turn.complete', async ($, e, next) => {
318 const result = await next(e)
319 if (!me) return result
320 const now = await $.clock.now()
321
322 if (e.agentId) {
323 // An agent ending its turn reports back to the main conversation.
324 const agentId = e.agentId
325 me.links = upsertLink(me.links, clean(agentId), me.id)
326 setAgent(agentId, row =>
327 row.isWorkflow ? { ...row, status: e.isAborted ? 'failed' : 'done', endedAt: now } : row,
328 )
329 void tick($)
330
331 return result
332 }
333
334 me.isActive = false
335 me.turnStartedAt = null
336 me.idleSince = now
337 await refreshIdentity($)
338 void tick($)
339
340 return result
341 })
342
343 on('agent.spawn', async ($, e, next) => {
344 const result = await next(e)
345 if (me && 'agentId' in result && result.agentId) {
346 const agentId = result.agentId
347 me.agents = me.agents.filter(row => row.id !== agentId)
348 me.agents.push({
349 id: agentId,
350 label: clean(e.description || e.subagentType),
351 type: clean(e.subagentType),
352 model: 'model' in result ? (result.model ?? null) : null,
353 status: 'running',
354 startedAt: await $.clock.now(),
355 endedAt: null,
356 isWorkflow: Boolean(e.workflow),
357 })
358 void beat($)
359 }
360
361 return result
362 }).catch(($, e, next) => next(e))
363
364 on('session.send', async ($, e, next) => {
365 const result = await next(e)
366 if (me && result.isDelivered) {
367 me.links = upsertLink(me.links, clean(e.agentId ?? me.id), clean(e.to))
368 void tick($)
369 }
370
371 return result
372 }).catch(($, e, next) => next(e))
373
374 on('session.receive', async ($, e, next) => {
375 // A delivery to one of our own agents was already counted by its send.
376 if (me && !e.agentId) {
377 const origin = e.origin
378 if ('teammate' in origin) me.links = upsertLink(me.links, clean(origin.teammate), me.id)
379 if (!('teammate' in origin) && origin.kind === 'peer') me.inboundUnknown += 1
380 void tick($)
381 }
382
383 return next(e)
384 }).catch(($, e, next) => next(e))
385
386 on('session.end', async ($, e, next) => {
387 if (me) {
388 me.isEnded = true
389 me.isActive = false
390 me.endedAt = await $.clock.now()
391 me.endReason = e.reason
392 await beat($)
393 }
394
395 return next(e)
396 })
397
398 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
399 const { Box, Text } = $.ui.resolve(e)
400 const now = await $.clock.now()
401 const self = await read($, selfId)
402 const beats = visible(await read($, fleet), now)
403
404 if (beats.length === 0) {
405 return <Text dimColor>Waiting for the first heartbeat (every {BEAT_MS / 1000}s)…</Text>
406 }
407
408 const working = beats.filter(b => liveness(b, now) === 'active').length
409 const agentsRunning = beats.reduce(
410 (sum, b) => sum + b.agents.filter(a => a.status === 'running').length,
411 0,
412 )
413
414 return (
415 <Box flexDirection="column">
416 <Text>
417 <Text bold>{beats.length}</Text> session{beats.length === 1 ? '' : 's'} ·{' '}
418 <Text color="success">{working} working</Text> · {agentsRunning} agent
419 {agentsRunning === 1 ? '' : 's'} running
420 </Text>
421 {beats.map(b => {
422 const state = liveness(b, now)
423 const pill = STATE_PILL[state]
424 const tint = b.color && Object.hasOwn(SESSION_COLORS, b.color) ? SESSION_COLORS[b.color] : undefined
425 const since =
426 state === 'active' && b.turnStartedAt
427 ? ago(now - b.turnStartedAt)
428 : state === 'idle' && b.idleSince
429 ? ago(now - b.idleSince)
430 : state === 'stale'
431 ? ago(now - b.beatAt)
432 : ''
433 const { shown, hidden } = agentRows(b.agents)
434 const running = b.agents.filter(a => a.status === 'running').length
435 const finished = b.agents.length - running
436 const messages = messageLines(b, beats)
437
438 return (
439 <Box
440 key={b.id}
441 flexDirection="column"
442 borderStyle="round"
443 borderColor={tint ?? (state === 'active' ? 'success' : 'subtle')}
444 paddingX={1}
445 marginTop={1}
446 >
447 <Box justifyContent="space-between">
448 <Text bold color={tint} wrap="truncate-end">
449 {clean(displayName(b))}
450 {b.id === self ? <Text dimColor> (this)</Text> : ''}
451 </Text>
452 <Text color={pill.color}>
453 {pill.glyph} {pill.word}
454 {since ? ` ${since}` : ''}
455 </Text>
456 </Box>
457
458 {b.autopilot && (
459 <Text color="claude" wrap="truncate-end">
460 {autopilotLine(b.autopilot, now)}
461 </Text>
462 )}
463
464 <Text wrap="truncate-end">
465 <Text dimColor>Context </Text>
466 {bar(b.context.percent, 10)} {tokensK(b.context.tokens)} of {tokensK(b.context.window)}
467 </Text>
468
469 <Text wrap="truncate-end">
470 <Text dimColor>Agents </Text>
471 {b.agents.length === 0 ? (
472 <Text dimColor>none yet</Text>
473 ) : (
474 `${running} working · ${finished} done`
475 )}
476 </Text>
477 {shown.map(agent => {
478 const glyph = AGENT_GLYPH[agent.status]
479 const took = (agent.endedAt ?? now) - agent.startedAt
480
481 return (
482 <Box key={agent.id} justifyContent="space-between">
483 <Text wrap="truncate-end">
484 {' '}
485 <Text color={glyph.color}>{glyph.glyph}</Text> {clean(agent.label)}
486 <Text dimColor>
487 {' '}
488 {agent.isWorkflow ? 'workflow · ' : ''}
489 {clean(agent.type)}
490 {agent.model ? ` · ${clean(shortModel(agent.model))}` : ''}
491 </Text>
492 </Text>
493 <Text dimColor>{ago(took)}</Text>
494 </Box>
495 )
496 })}
497 {hidden > 0 && <Text dimColor>{' '}+{hidden} more</Text>}
498
499 {messages.length > 0 && (
500 <Text wrap="truncate-end">
501 <Text dimColor>Messages </Text>
502 {messages.join(' ')}
503 </Text>
504 )}
505
506 <Text dimColor wrap="truncate-end">
507 {clean(shortModel(b.model))} · {clean(basename(b.cwd))}
508 {b.costUsd !== null ? ` · $${b.costUsd.toFixed(2)} API-equivalent` : ''}
509 </Text>
510 </Box>
511 )
512 })}
513 </Box>
514 )
515 })
516}
517hooks/fleet.ts 355 lines1import type { AgentRow, AgentRunState, AutopilotBadge, Beat, Link, Liveness } from '../types'
2
3export const BEAT_MS = 15_000
4export const STALE_MS = 3 * 60_000
5export const DROP_MS = 60 * 60_000
6/** Heartbeat files older than this are deleted. */
7export const CLEANUP_MINUTES = 24 * 60
8export const AGENT_ROWS = 6
9
10/** The /color names Claude Code records, as hex so every surface draws them. */
11export const SESSION_COLORS: Record<string, string> = {
12 red: '#e5534b',
13 blue: '#539bf5',
14 green: '#57ab5a',
15 yellow: '#c69026',
16 purple: '#b083f0',
17 orange: '#e0823d',
18 pink: '#e275ad',
19 cyan: '#39c5cf',
20}
21
22export function liveness(beat: Beat, now: number): Liveness {
23 if (beat.isEnded) return 'ended'
24 if (now - beat.beatAt > STALE_MS) return 'stale'
25 if (beat.isActive) return 'active'
26
27 return 'idle'
28}
29
30/**
31 * Beats worth showing: never an ended or cleared one (a resume brings it
32 * back), any other until DROP_MS without a beat; working first, then newest.
33 */
34export function visible(beats: Beat[], now: number): Beat[] {
35 const order: Record<Liveness, number> = { active: 0, idle: 1, stale: 2, ended: 3 }
36
37 return beats
38 .filter(beat => !beat.isEnded && now - beat.beatAt <= DROP_MS)
39 .sort((a, b) => order[liveness(a, now)] - order[liveness(b, now)] || b.beatAt - a.beatAt)
40}
41
42export function shortId(id: string): string {
43 return id.replace(/^session_/, '').slice(0, 8)
44}
45
46export function displayName(beat: Beat): string {
47 return beat.name ?? beat.label
48}
49
50const IDENTITY_FIELD: Record<string, string> = {
51 'custom-title': 'customTitle',
52 'agent-name': 'agentName',
53 'agent-color': 'agentColor',
54 'ai-title': 'aiTitle',
55}
56
57/**
58 * Picks the session's name and color from whole transcript rows grep printed,
59 * the last of each kind winning: /rename's title, else the agent name, else the
60 * app's auto-title. Only a row whose TOP-LEVEL type names the kind counts; a
61 * malformed row, or a color that is not one of SESSION_COLORS, is skipped.
62 */
63export function identityFromRows(lines: string[]): { name: string | null; color: string | null } {
64 const last: Record<string, string> = {}
65 for (const line of lines) {
66 let row: unknown
67 try {
68 row = JSON.parse(line)
69 } catch {
70 continue
71 }
72 if (typeof row !== 'object' || row === null) continue
73 const record = row as Record<string, unknown>
74 const kind = record.type
75 if (typeof kind !== 'string' || !Object.hasOwn(IDENTITY_FIELD, kind)) continue
76 const value = record[IDENTITY_FIELD[kind] as string]
77 if (typeof value !== 'string' || !value) continue
78 if (kind === 'agent-color' && !Object.hasOwn(SESSION_COLORS, value)) continue
79 last[kind] = value
80 }
81
82 return {
83 name: last['custom-title'] ?? last['agent-name'] ?? last['ai-title'] ?? null,
84 color: last['agent-color'] ?? null,
85 }
86}
87
88/**
89 * Strips control, bidi and format characters from a foreign string and caps
90 * its length with an ellipsis; anything that is not a string becomes ''.
91 */
92export function clean(s: unknown, max = 120): string {
93 if (typeof s !== 'string') return ''
94 const stripped = s.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '')
95 const points = Array.from(stripped)
96 if (points.length <= max) return stripped
97
98 return `${points.slice(0, Math.max(0, max - 1)).join('')}…`
99}
100
101export const MAX_BEAT_BYTES = 64 * 1024
102const MAX_AGENTS = 50
103const MAX_LINKS = 100
104const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60_000
105const BEAT_FUTURE_SKEW_MS = 5 * 60_000
106const AGENT_STATES: readonly string[] = ['running', 'waiting', 'done', 'failed']
107
108/** The heartbeat file name of a session id (the id itself is validated, so this is the id plus .json). */
109export function beatFile(id: string): string {
110 return `${id.replace(/[^\w.-]/g, '_')}.json`
111}
112
113/** At most MAX_AGENTS rows: every running one, then the newest (last) of the rest; original order kept. */
114export function capAgents(rows: AgentRow[]): AgentRow[] {
115 if (rows.length <= MAX_AGENTS) return rows
116 const running = rows.filter(row => row.status === 'running').length
117 let room = Math.max(0, MAX_AGENTS - running)
118 const keep = new Array<boolean>(rows.length).fill(false)
119 for (let i = rows.length - 1; i >= 0; i--) {
120 if (rows[i]?.status === 'running') {
121 keep[i] = true
122 } else if (room > 0) {
123 keep[i] = true
124 room--
125 }
126 }
127
128 return rows.filter((_, i) => keep[i])
129}
130
131function isObject(value: unknown): value is Record<string, unknown> {
132 return typeof value === 'object' && value !== null && !Array.isArray(value)
133}
134
135function isNum(value: unknown): value is number {
136 return typeof value === 'number' && Number.isFinite(value)
137}
138
139function isStamp(value: unknown, now: number): value is number {
140 return isNum(value) && Math.abs(value - now) <= TEN_YEARS_MS
141}
142
143function isNumOrNull(value: unknown): value is number | null {
144 return value === null || isNum(value)
145}
146
147function isStampOrNull(value: unknown, now: number): value is number | null {
148 return value === null || isStamp(value, now)
149}
150
151function isStrOrNull(value: unknown): value is string | null {
152 return value === null || typeof value === 'string'
153}
154
155/**
156 * A foreign heartbeat, checked field by field against everything the render
157 * path touches, or null. The id must be a plain file-safe name and equal the
158 * file's stem, so a beat cannot claim another session's id or a path.
159 */
160export function validBeat(value: unknown, fileName: string, now = Date.now()): Beat | null {
161 if (!isObject(value)) return null
162 const { id, name, color, label, cwd, model, context } = value
163 if (typeof id !== 'string' || id.length === 0 || id.length > 128) return null
164 if (!/^[\w.-]+$/.test(id) || id === '.' || id === '..') return null
165 if (fileName !== beatFile(id) || fileName !== `${id}.json`) return null
166 if (!isStrOrNull(name) || !isStrOrNull(color)) return null
167 if (typeof label !== 'string' || typeof cwd !== 'string' || typeof model !== 'string') return null
168 if (!isStamp(value.startedAt, now) || !isStamp(value.beatAt, now)) return null
169 if (value.beatAt > now + BEAT_FUTURE_SKEW_MS) return null
170 if (typeof value.isActive !== 'boolean' || typeof value.isEnded !== 'boolean') return null
171 if (!isStampOrNull(value.turnStartedAt, now) || !isStampOrNull(value.idleSince, now)) return null
172 const endedAt = value.endedAt ?? null
173 const endReason = value.endReason ?? null
174 if (!isStampOrNull(endedAt, now) || !isStrOrNull(endReason)) return null
175 if (!isObject(context)) return null
176 if (!isNumOrNull(context.percent) || !isNumOrNull(context.tokens) || !isNum(context.window)) return null
177 if (!isNumOrNull(value.costUsd) || !isNum(value.inboundUnknown)) return null
178
179 const agents = value.agents
180 const links = value.links
181 if (!Array.isArray(agents) || !Array.isArray(links)) return null
182 const rows: AgentRow[] = []
183 for (const agent of agents) {
184 if (!isObject(agent)) return null
185 if (typeof agent.id !== 'string' || typeof agent.label !== 'string' || typeof agent.type !== 'string') return null
186 if (!isStrOrNull(agent.model) || typeof agent.isWorkflow !== 'boolean') return null
187 if (typeof agent.status !== 'string' || !AGENT_STATES.includes(agent.status)) return null
188 if (!isStamp(agent.startedAt, now) || !isStampOrNull(agent.endedAt, now)) return null
189 rows.push({
190 id: agent.id,
191 label: agent.label,
192 type: agent.type,
193 model: agent.model,
194 status: agent.status as AgentRunState,
195 startedAt: agent.startedAt,
196 endedAt: agent.endedAt,
197 isWorkflow: agent.isWorkflow,
198 })
199 }
200 const pairs: Link[] = []
201 for (const link of links) {
202 if (!isObject(link)) return null
203 if (typeof link.from !== 'string' || typeof link.to !== 'string' || !isNum(link.count)) return null
204 pairs.push({ from: link.from, to: link.to, count: link.count })
205 }
206 const keptRows = capAgents(rows)
207 const keptPairs = pairs.slice(-MAX_LINKS)
208
209 return {
210 id,
211 name,
212 color,
213 label,
214 cwd,
215 model,
216 startedAt: value.startedAt,
217 beatAt: value.beatAt,
218 isActive: value.isActive,
219 turnStartedAt: value.turnStartedAt,
220 idleSince: value.idleSince,
221 isEnded: value.isEnded,
222 endedAt,
223 endReason,
224 context: { percent: context.percent, tokens: context.tokens, window: context.window },
225 costUsd: value.costUsd,
226 agents: keptRows,
227 links: keptPairs,
228 inboundUnknown: value.inboundUnknown,
229 }
230}
231
232/** The autopilot badge as the autopilot mod wrote it, or null when it is off or malformed. */
233export function validBadge(value: unknown, now = Date.now()): AutopilotBadge | null {
234 if (!isObject(value) || value.isOn !== true) return null
235 const { phase, until, threshold, restarts, maxRestarts, waitUntil, hasGoal } = value
236 if (phase !== undefined && typeof phase !== 'string') return null
237 if (until !== undefined && !isStamp(until, now)) return null
238 if (threshold !== undefined && !isNum(threshold)) return null
239 if (restarts !== undefined && !isNum(restarts)) return null
240 if (maxRestarts !== undefined && !isNumOrNull(maxRestarts)) return null
241 if (waitUntil !== undefined && !isStampOrNull(waitUntil, now)) return null
242 if (hasGoal !== undefined && typeof hasGoal !== 'boolean') return null
243
244 return { isOn: true, phase, until, threshold, restarts, maxRestarts, waitUntil, hasGoal }
245}
246
247/** "claude-opus-5-5" → "opus 5.5"; anything else as is. */
248export function shortModel(model: string | null): string {
249 if (!model) return ''
250 const match = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(model)
251 if (!match) return model
252
253 return `${match[1]} ${match[2]}.${match[3]}`
254}
255
256/** Running agents first, then the most recently started; at most `limit`. */
257export function agentRows(agents: AgentRow[], limit = AGENT_ROWS): { shown: AgentRow[]; hidden: number } {
258 const rank: Record<AgentRunState, number> = { running: 0, waiting: 1, failed: 2, done: 3 }
259 const sorted = [...agents].sort((a, b) => rank[a.status] - rank[b.status] || b.startedAt - a.startedAt)
260
261 return { shown: sorted.slice(0, limit), hidden: Math.max(0, sorted.length - limit) }
262}
263
264export function upsertLink(links: Link[], from: string, to: string): Link[] {
265 const found = links.find(link => link.from === from && link.to === to)
266 if (!found) return [...links, { from, to, count: 1 }]
267
268 return links.map(link => (link === found ? { ...link, count: link.count + 1 } : link))
269}
270
271/**
272 * The message lines of one card: its own links seen from this session
273 * (→ out, ← in, a → b between its agents) and the links other sessions hold
274 * toward it. Addresses are named by the card's agents and the fleet's sessions.
275 */
276export function messageLines(beat: Beat, beats: Beat[]): string[] {
277 const names = new Map<string, string>()
278 for (const other of beats) {
279 names.set(other.id, clean(displayName(other)))
280 names.set(shortId(other.id), clean(displayName(other)))
281 }
282 for (const agent of beat.agents) names.set(agent.id, clean(agent.label))
283 const nameOf = (address: string) => names.get(address) ?? names.get(shortId(address)) ?? clean(address)
284 const isSelf = (address: string) => address === beat.id || address === shortId(beat.id)
285
286 const lines = beat.links.map(link => {
287 if (isSelf(link.from)) return `→ ${nameOf(link.to)} ×${link.count}`
288 if (isSelf(link.to)) return `← ${nameOf(link.from)} ×${link.count}`
289
290 return `${nameOf(link.from)} → ${nameOf(link.to)} ×${link.count}`
291 })
292
293 let knownInbound = 0
294 for (const other of beats) {
295 if (other.id === beat.id) continue
296 for (const link of other.links) {
297 if (!isSelf(link.to)) continue
298 knownInbound += link.count
299 lines.push(`← ${clean(displayName(other))} ×${link.count}`)
300 }
301 }
302 const unknown = beat.inboundUnknown - knownInbound
303 if (unknown > 0) lines.push(`← another session ×${unknown}`)
304
305 return lines
306}
307
308export function bar(percent: number | null, width = 12): string {
309 if (percent === null) return `${'·'.repeat(width)} ?%`
310 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
311
312 return `${'█'.repeat(filled)}${'░'.repeat(width - filled)} ${String(Math.round(percent)).padStart(3)}%`
313}
314
315/**
316 * The autopilot badge: phase, time left, trigger and restarts, the goal flag;
317 * on a 5-hour wait, when it resumes instead of the time left.
318 */
319export function autopilotLine(badge: AutopilotBadge, now: number): string {
320 const parts = [`⚙ autopilot · ${clean(badge.phase ?? 'on', 32)}`]
321 if (badge.phase === 'waiting' && badge.waitUntil) {
322 parts.push(`resumes ${new Date(badge.waitUntil).toISOString().slice(11, 16)} UTC`)
323 } else if (badge.until) {
324 parts.push(`${ago(Math.max(0, badge.until - now))} left`)
325 }
326 if (badge.threshold) parts.push(`trigger ${badge.threshold}%`)
327 parts.push(`restarts ${badge.restarts ?? 0}/${badge.maxRestarts ?? '∞'}`)
328 if (badge.hasGoal) parts.push('⚑ goal')
329
330 return parts.join(' · ')
331}
332
333export function ago(ms: number): string {
334 const s = Math.max(0, Math.round(ms / 1000))
335 if (s < 60) return `${s}s`
336 if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
337
338 return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
339}
340
341export function tokensK(tokens: number | null): string {
342 if (tokens === null) return '?'
343 if (tokens >= 1_000_000) return `${+(tokens / 1_000_000).toFixed(1)}M`
344
345 return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens)
346}
347
348export function basename(path: string): string {
349 let end = path.length
350 while (end > 0 && path[end - 1] === '/') end--
351 if (end === 0) return path
352
353 return path.slice(path.lastIndexOf('/', end - 1) + 1, end)
354}
355types/index.d.ts 67 lines1export type AgentRunState = 'running' | 'waiting' | 'done' | 'failed'
2
3/** One subagent or workflow agent of a session. */
4export type AgentRow = {
5 id: string
6 label: string
7 type: string
8 model: string | null
9 status: AgentRunState
10 startedAt: number
11 endedAt: number | null
12 isWorkflow: boolean
13}
14
15/** Message counts between two addresses: a session id, an agent id, or a name. */
16export type Link = { from: string; to: string; count: number }
17
18/** One session's heartbeat, as it writes it to ~/.claude/fleet/<id>.json. */
19export type Beat = {
20 id: string
21 /** The /rename name, else the app's auto-title; null when neither is set. */
22 name: string | null
23 /** The /color color name (blue, green, ...); null when unset. */
24 color: string | null
25 /** Fallback label: <folder>·<short id>. */
26 label: string
27 cwd: string
28 model: string
29 startedAt: number
30 beatAt: number
31 isActive: boolean
32 turnStartedAt: number | null
33 idleSince: number | null
34 isEnded: boolean
35 endedAt: number | null
36 /** `clear` for /clear, else the engine's reason (quit, logout, ...). */
37 endReason: string | null
38 context: { percent: number | null; tokens: number | null; window: number }
39 costUsd: number | null
40 agents: AgentRow[]
41 links: Link[]
42 /** Messages from other sessions whose sender could not be told. */
43 inboundUnknown: number
44 /** The autopilot mod's status for this session, attached when the fleet reads it. */
45 autopilot?: AutopilotBadge | null
46}
47
48/** What the autopilot mod writes to ~/.claude/autopilot/<id>.json. */
49export type AutopilotBadge = {
50 isOn: boolean
51 phase?: string
52 until?: number
53 threshold?: number
54 restarts?: number
55 maxRestarts?: number | null
56 waitUntil?: number | null
57 hasGoal?: boolean
58}
59
60export type Liveness = 'active' | 'idle' | 'stale' | 'ended'
61
62declare module 'claude-code' {
63 interface PluginState {
64 'agent-fleet': { fleet: Beat[]; selfId: string }
65 }
66}
67