Status line and pane for the crew this session raised inside Herdr, and the stream it conducts

A Claude Code mod for sessions that raise a crew inside Herdr with the herdr-jutsu plugin from this marketplace: a /crew pane (and, optionally, a status line) showing each member's status, name, where Herdr shows it, kind, issue, directory and age, plus the fable-conductor stream the session is conducting.
/crew opens the pane.It is read-only. It runs three Herdr listings (agent list, tab list, workspace list) and reads two kinds of file. It sends nothing to any pane.
HERDR_ENV=1). Outside one it draws "Not inside herdr" and polls nothing.herdr-jutsu registry, found through $JUTSU_STATE_DIR, then $XDG_STATE_HOME/herdr-jutsu, then <cwd>/.jutsu/state. Without it "My crew" is empty; the "All" view still lists every live agent Herdr reports.docs/fable-streams/*/stream.md under the session's directory, as fable-conductor writes it. Without it the table is absent.In /config:
Crew pane (default on): poll Herdr and show this session's crew.Speak when a member blocks (default off): say it aloud as well as showing a toast.Status line (default off): also show the crew in the status line. The pane always shows it. While off, the mod never sets status-line text, and clears any line an earlier version set.Crew pane option in /config.DMOKONG_MODS=0 in the environment silences every mod from this marketplace for that process.claude plugin disable crew-pane@dmokong-plugins.Headless runs (claude -p) poll nothing.
claude plugin validate plugins/crew-pane claude plugin test plugins/crew-pane claude --plugin-dir plugins/crew-pane
If the plugin is also installed, --plugin-dir overrides the installed copy for that session.
hooks/register.tsx 296 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5 COLUMN_GAP,
6 EMPTY,
7 alerts,
8 countColor,
9 divider,
10 headerCells,
11 joinCrew,
12 layout,
13 memberCells,
14 statusText,
15 streamColumns,
16 streamCounts,
17 streamHeader,
18 streamLead,
19} from './lib/crew'
20import { parseAgents, parseRegistry, parseStream, parseTabs, parseWorkspaces } from './lib/sources'
21import type { CrewState, StreamSummary } from '../types'
22
23const crew = atom({ plugin: 'crew-pane', key: 'crew' } as const, EMPTY)
24
25const PANE = 'crew'
26const POLL_MS = 10_000
27const SLOW_EVERY = 6 // polls: 6 x 10 s = once a minute
28const HERDR_TIMEOUT_MS = 5_000
29const STREAMS_DIR = 'docs/fable-streams'
30const MAX_STREAMS = 2
31const FOCUS_HINT = '* focused tab'
32
33/** A repo-relative path made absolute under the session's directory, so it never depends on where the host resolves it. */
34function inSession(path: string): string {
35 return cwd === '' ? path : `${cwd}/${path}`
36}
37
38// The engine follows `$` only into functions declared at the top of this file, so what the
39// hooks share lives here rather than in `register`'s closure. `register` sets the options.
40let isOff = false
41let isSpeaking = false
42let isStatusLine = false
43let myPane = ''
44let cwd = ''
45let registryDir: string | null = null
46let ticks = 0
47// Files are re-read only when their modification time moves.
48const fileCache = new Map<string, { mtimeMs: number; text: string }>()
49
50async function readCached($: EngineInterface, path: string, mtimeMs: number): Promise<string> {
51 const hit = fileCache.get(path)
52 if (hit !== undefined && hit.mtimeMs === mtimeMs) return hit.text
53 const text = String(await $.fs.read(path))
54 fileCache.set(path, { mtimeMs, text })
55 return text
56}
57
58async function findRegistryDir($: EngineInterface): Promise<string | null> {
59 const home = (await $.env.get('HOME')) ?? ''
60 const stateHome = (await $.env.get('XDG_STATE_HOME')) ?? (home === '' ? '' : `${home}/.local/state`)
61 const candidates = [
62 (await $.env.get('JUTSU_STATE_DIR')) ?? '',
63 stateHome === '' ? '' : `${stateHome}/herdr-jutsu`,
64 inSession('.jutsu/state'),
65 ]
66 for (const candidate of candidates) {
67 if (candidate !== '' && (await $.fs.exists(candidate))) return candidate
68 }
69 return null
70}
71
72async function readRegistry($: EngineInterface): Promise<string[]> {
73 if (registryDir === null) registryDir = await findRegistryDir($)
74 if (registryDir === null) return []
75 const dir = registryDir
76 const entries = (await $.fs.list(dir))
77 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.jsonl'))
78 .sort((a, b) => a.mtimeMs - b.mtimeMs)
79 const texts: string[] = []
80 for (const entry of entries) texts.push(await readCached($, `${dir}/${entry.name}`, entry.mtimeMs))
81 return texts
82}
83
84async function readStreams($: EngineInterface): Promise<StreamSummary[]> {
85 const found: { mtimeMs: number; summary: StreamSummary }[] = []
86 const root = inSession(STREAMS_DIR)
87 for (const dir of await $.fs.list(root)) {
88 if (dir.kind !== 'dir') continue
89 const ledger = (await $.fs.list(`${root}/${dir.name}`)).find(entry => entry.name === 'stream.md')
90 if (ledger === undefined) continue
91 const summary = parseStream(await readCached($, `${root}/${dir.name}/stream.md`, ledger.mtimeMs))
92 if (summary !== null && summary.phase !== 'done') found.push({ mtimeMs: ledger.mtimeMs, summary })
93 }
94 return found.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_STREAMS).map(item => item.summary)
95}
96
97/** The output of `herdr <noun> list`, or '' when it could not be read. Never throws. */
98async function listing($: EngineInterface, noun: 'tab' | 'workspace'): Promise<string> {
99 try {
100 const listed = await $.process.run(['herdr', noun, 'list'], { timeoutMs: HERDR_TIMEOUT_MS })
101 return listed.exitCode === 0 ? listed.stdout : ''
102 } catch {
103 return ''
104 }
105}
106
107/** One read of every source into state, then the status line and any alerts. Never throws. */
108async function poll($: EngineInterface, withStreams: boolean): Promise<void> {
109 try {
110 const before = await read($, crew)
111 const listed = await $.process.run(['herdr', 'agent', 'list'], { timeoutMs: HERDR_TIMEOUT_MS })
112 const agents = listed.exitCode === 0 ? parseAgents(listed.stdout) : null
113 if (agents === null) {
114 await update($, crew, state => ({ ...state, error: 'herdr unavailable' }))
115 return
116 }
117 // Where herdr shows each pane. Read-only like the agent list, and optional: without them
118 // the WHERE column falls back to the tab id.
119 const tabs = parseTabs(await listing($, 'tab'))
120 const workspaces = parseWorkspaces(await listing($, 'workspace'))
121 const rows = parseRegistry(await readRegistry($))
122 const streams = withStreams ? await readStreams($).catch(() => before.streams) : before.streams
123 const now = await $.clock.now()
124 const members = joinCrew({ rows, agents, myPane, showAll: before.showAll, prev: before.members, now, tabs, workspaces })
125 await update($, crew, state => ({ ...state, members, streams, error: null }))
126 if (isStatusLine) $.ui.status(statusText(members))
127 for (const alert of alerts(before.members, members)) {
128 $.ui.toast(alert.text)
129 if (alert.isBlocked && isSpeaking) void $.audio.speak(alert.text).catch(() => {})
130 }
131 } catch {
132 await update($, crew, state => ({ ...state, error: 'crew sources unavailable' })).catch(() => {})
133 }
134}
135
136async function isPaneShown($: EngineInterface): Promise<boolean> {
137 return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
138}
139
140export const register: Register = (on, options) => {
141 isOff = options.enabled === 'off'
142 isSpeaking = options.speak === 'on'
143 isStatusLine = options.statusLine === 'on'
144
145 on('session.start', async ($, e, next) => {
146 if ((await $.env.get('DMOKONG_MODS')) === '0') isOff = true
147 if (isOff || !e.isInteractive) return next(e)
148 cwd = e.cwd
149 // A session upgraded mid-flight may still hold the line an earlier version set.
150 if (!isStatusLine) $.ui.status(undefined)
151 await $.command.register({ name: 'crew', description: 'Open the crew pane: herdr members and the conducted stream' })
152 const isInside = (await $.env.get('HERDR_ENV')) === '1'
153 if (isInside) myPane = (await $.env.get('HERDR_PANE_ID')) ?? ''
154 // What a draw depends on lives in state, so writing it redraws a pane drawn before this ran.
155 const environment: CrewState['environment'] = isInside ? 'inside' : 'outside'
156 await update($, crew, state => ({ ...state, environment }))
157 if (!isInside) return next(e)
158
159 $.clock.after(0, () => void poll($, true))
160 $.clock.every(POLL_MS, () => {
161 void (async () => {
162 ticks += 1
163 const isSlowTick = ticks % SLOW_EVERY === 0
164 const { members } = await read($, crew)
165 if (!isSlowTick && members.length === 0 && !(await isPaneShown($))) return
166 await poll($, isSlowTick)
167 })().catch(() => {})
168 })
169 return next(e)
170 })
171
172 on('command.run', { command: 'crew' }, async $ => {
173 // Before session.start has said where it runs, the first poll is session.start's own.
174 if ((await read($, crew)).environment === 'inside') await poll($, true)
175 await $.ui.open({ id: PANE, title: 'Crew' })
176 const { members } = await read($, crew)
177 return { text: `Crew pane opened: ${members.length} ${members.length === 1 ? 'member' : 'members'}.` }
178 })
179
180 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
181 const { Box, Button, Text } = $.ui.resolve(e)
182 // Read the state before any early return, so every draw subscribes and a later write redraws it.
183 const state = await read($, crew)
184 if (state.environment === 'unknown') {
185 return (
186 <Box>
187 <Text dimColor>Starting…</Text>
188 </Box>
189 )
190 }
191 if (state.environment === 'outside') {
192 return (
193 <Box>
194 <Text dimColor>Not inside herdr. The crew pane only reads a herdr session it is running in.</Text>
195 </Box>
196 )
197 }
198 const now = await $.clock.now()
199 const width = e.props.bodyColumns
200 const shape = layout(state.members, width, now)
201 const summary = statusText(state.members)
202 const streamCols = streamColumns(state.streams, width)
203 return (
204 <Box flexDirection="column">
205 <Box>
206 <Text bold color="claude">
207 {state.showAll ? 'All herdr agents' : 'My crew'}
208 </Text>
209 {summary !== undefined && <Text color="inactive">{` ${summary.replace(/^crew /, '')}`}</Text>}
210 </Box>
211 {state.members.length === 0 && (
212 <Text dimColor>{state.showAll ? 'No other live herdr agents.' : 'No crew spawned from this pane.'}</Text>
213 )}
214 {state.members.length > 0 && (
215 <Box>
216 {headerCells(shape).map((cell, at) => (
217 <Text key={`head-${cell.key}`} wrap="truncate-end" bold underline color={cell.color}>
218 {at === 0 ? cell.text : COLUMN_GAP + cell.text}
219 </Text>
220 ))}
221 </Box>
222 )}
223 {state.members.map(member => (
224 <Box>
225 {memberCells(member, now, shape).map((cell, at) => (
226 <Text
227 key={`${member.paneId}-${cell.key}`}
228 wrap="truncate-end"
229 color={cell.color}
230 bold={cell.bold === true}
231 dimColor={cell.dim === true}
232 >
233 {at === 0 ? cell.text : COLUMN_GAP + cell.text}
234 </Text>
235 ))}
236 </Box>
237 ))}
238 {state.members.some(member => member.isFocusedTab === true) && (
239 <Text wrap="truncate-end" color="inactive">
240 {FOCUS_HINT}
241 </Text>
242 )}
243 {state.streams.length > 0 && (
244 <Box flexDirection="column">
245 <Text wrap="truncate-end" color="inactive" dimColor>
246 {divider(width)}
247 </Text>
248 <Box>
249 {streamHeader(streamCols).map((heading, at) => (
250 <Text wrap="truncate-end" bold underline color="text">
251 {at === 0 ? heading : COLUMN_GAP + heading}
252 </Text>
253 ))}
254 </Box>
255 {state.streams.map(stream => (
256 <Box flexDirection="column">
257 <Box>
258 <Text bold wrap="truncate-end">
259 {streamLead(stream, streamCols).name}
260 </Text>
261 <Text color="claude">{COLUMN_GAP + streamLead(stream, streamCols).phase}</Text>
262 {streamCounts(stream).map(({ status, count }, at) => (
263 <Text color={countColor(status)}>{`${at === 0 ? COLUMN_GAP : ' · '}${count} ${status}`}</Text>
264 ))}
265 </Box>
266 {stream.running.map(task => (
267 <Text wrap="truncate-end" color="suggestion">
268 {` ↳ ${task}`}
269 </Text>
270 ))}
271 </Box>
272 ))}
273 </Box>
274 )}
275 <Text wrap="truncate-end" color="inactive" dimColor>
276 {divider(width)}
277 </Text>
278 {state.error !== null && <Text color="warning">{state.error}</Text>}
279 <Box>
280 <Button
281 key="all"
282 hotkey="a"
283 label={state.showAll ? 'Mine' : 'All'}
284 onPress={async () => {
285 await update($, crew, s => ({ ...s, showAll: !s.showAll }))
286 await poll($, false)
287 }}
288 />
289 <Text> </Text>
290 <Button key="refresh" hotkey="r" variant="primary" label="Refresh" onPress={() => poll($, true)} />
291 </Box>
292 </Box>
293 )
294 })
295}
296hooks/lib/crew.ts 309 lines1import type { CrewState, Member, MemberStatus, StreamSummary } from '../../types'
2import type { LiveAgent, RegistryRow, TabInfo, WorkspaceInfo } from './sources'
3
4export const EMPTY: CrewState = { members: [], streams: [], showAll: false, error: null, environment: 'unknown' }
5
6function liveStatus(status: string): MemberStatus {
7 switch (status) {
8 case 'idle':
9 case 'working':
10 case 'blocked':
11 case 'done':
12 case 'unknown':
13 return status
14 default:
15 return 'unknown'
16 }
17}
18
19export function joinCrew(input: {
20 rows: readonly RegistryRow[]
21 agents: readonly LiveAgent[]
22 myPane: string
23 showAll: boolean
24 prev: readonly Member[]
25 now: number
26 tabs?: readonly TabInfo[]
27 workspaces?: readonly WorkspaceInfo[]
28}): Member[] {
29 const { rows, agents, myPane, showAll, prev, now } = input
30 const tabById = new Map((input.tabs ?? []).map(tab => [tab.tabId, tab]))
31 const workspaceById = new Map((input.workspaces ?? []).map(workspace => [workspace.workspaceId, workspace]))
32
33 /** What herdr itself shows for the pane's place, plus the directory it works in. */
34 const place = (live: LiveAgent | undefined): Pick<Member, 'where' | 'isFocusedTab' | 'dir'> => {
35 if (live === undefined) return { where: '', isFocusedTab: false, dir: '' }
36 const tab = tabById.get(live.tabId)
37 const workspace = workspaceById.get(live.workspaceId)
38 const where =
39 tab !== undefined && workspace !== undefined && workspace.label !== ''
40 ? `${workspace.label}/${tab.label}`
41 : live.tabId
42 return { where, isFocusedTab: tab?.isFocused === true, dir: live.cwd.split('/').filter(part => part !== '').pop() ?? '' }
43 }
44 const liveByPane = new Map(agents.map(agent => [agent.paneId, agent]))
45 const rowByPane = new Map(rows.map(row => [row.paneId, row]))
46 const prevByPane = new Map(prev.map(member => [member.paneId, member]))
47
48 const stamp = (paneId: string, status: MemberStatus): number => {
49 const before = prevByPane.get(paneId)
50 return before !== undefined && before.status === status ? before.since : now
51 }
52
53 if (showAll) {
54 return agents
55 .filter(agent => agent.paneId !== myPane)
56 .map(agent => {
57 const row = rowByPane.get(agent.paneId)
58 const status = liveStatus(agent.status)
59 return {
60 // The registry name, else the name herdr holds for the agent, else what its terminal says.
61 name: row?.name ?? (agent.name !== '' ? agent.name : agent.title !== '' ? agent.title : agent.paneId),
62 kind: agent.kind,
63 issue: row?.issue ?? '',
64 paneId: agent.paneId,
65 status,
66 since: stamp(agent.paneId, status),
67 ...place(agent),
68 }
69 })
70 }
71
72 const members: Member[] = []
73 for (const row of rows) {
74 if (row.parentPane !== myPane) continue
75 const live = liveByPane.get(row.paneId)
76 // A member with no live pane is shown only if this session saw it alive.
77 if (live === undefined && !prevByPane.has(row.paneId)) continue
78 const status = live === undefined ? 'gone' : liveStatus(live.status)
79 members.push({
80 name: row.name,
81 kind: row.kind !== '' ? row.kind : live?.kind ?? '',
82 issue: row.issue,
83 paneId: row.paneId,
84 status,
85 since: stamp(row.paneId, status),
86 ...place(live),
87 })
88 }
89 return members
90}
91
92export type Alert = { text: string; isBlocked: boolean }
93
94export function alerts(prev: readonly Member[], next: readonly Member[]): Alert[] {
95 const before = new Map(prev.map(member => [member.paneId, member.status]))
96 const out: Alert[] = []
97 for (const member of next) {
98 const was = before.get(member.paneId)
99 if (was === member.status) continue
100 if (member.status === 'blocked') out.push({ text: `${member.name} is blocked`, isBlocked: true })
101 else if (
102 // `done` is herdr's "finished, not yet looked at": announced from any known status, so a
103 // turn that began and ended between two polls is not missed. A member that was `gone` or
104 // never seen has no earlier status to finish from.
105 (member.status === 'done' && was !== undefined && was !== 'gone') ||
106 (was === 'working' && member.status === 'idle')
107 ) {
108 out.push({ text: `${member.name} finished`, isBlocked: false })
109 } else if (member.status === 'gone' && was !== undefined) {
110 out.push({ text: `${member.name} is gone`, isBlocked: false })
111 }
112 }
113 return out
114}
115
116const STATUS_ORDER: readonly MemberStatus[] = ['blocked', 'working', 'done', 'idle', 'unknown']
117
118export function statusText(members: readonly Member[]): string | undefined {
119 const parts = STATUS_ORDER.map(status => [status, members.filter(m => m.status === status).length] as const)
120 .filter(([, count]) => count > 0)
121 .map(([status, count]) => `${count} ${status}`)
122 return parts.length === 0 ? undefined : `crew ${parts.join(' · ')}`
123}
124
125export function age(since: number, now: number): string {
126 const seconds = Math.max(0, Math.floor((now - since) / 1000))
127 if (seconds < 60) return `${seconds}s`
128 if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
129 return `${Math.floor(seconds / 3600)}h`
130}
131
132const cut = (value: string, width: number): string =>
133 width <= 0 ? '' : value.length <= width ? value : `${value.slice(0, width - 1)}…`
134
135export type ColumnKey = 'status' | 'name' | 'where' | 'pane' | 'kind' | 'issue' | 'dir' | 'age'
136
137/** One piece of a drawn row: its padded text and how to paint it. `color` is a theme key. */
138export type Cell = { key: ColumnKey; text: string; color?: string; bold?: boolean; dim?: boolean }
139
140/** The columns one draw shows, in order, and the width of each. */
141export type Layout = { keys: ColumnKey[]; widths: Record<ColumnKey, number> }
142
143const HEADINGS: Record<ColumnKey, string> = {
144 status: 'STATUS',
145 name: 'NAME',
146 where: 'WHERE',
147 pane: 'PANE',
148 kind: 'KIND',
149 issue: 'ISSUE',
150 dir: 'DIR',
151 age: 'AGE',
152}
153const ORDER: readonly ColumnKey[] = ['status', 'name', 'where', 'pane', 'kind', 'issue', 'dir', 'age']
154/** Given up first when the pane is too narrow; status, name and age are never dropped. */
155const DROP_ORDER: readonly ColumnKey[] = ['dir', 'pane', 'issue', 'kind', 'where']
156const GAP = 2
157const MIN_NAME = 6
158const FOCUS_MARK = '*'
159
160function value(member: Member, key: ColumnKey, now: number): string {
161 switch (key) {
162 case 'status':
163 return member.status
164 case 'name':
165 return member.name
166 case 'where':
167 // A member drawn from state written before these fields existed has none.
168 return `${member.where ?? ''}${member.isFocusedTab === true ? FOCUS_MARK : ''}`
169 case 'pane':
170 return member.paneId
171 case 'kind':
172 return member.kind
173 case 'issue':
174 return member.issue
175 case 'dir':
176 return member.dir ?? ''
177 case 'age':
178 return age(member.since, now)
179 }
180}
181
182const totalWidth = (keys: readonly ColumnKey[], widths: Record<ColumnKey, number>): number =>
183 keys.reduce((sum, key) => sum + widths[key], 0) + GAP * Math.max(0, keys.length - 1)
184
185/**
186 * Chosen once per draw from every member shown, so the rows and the header line up. A column
187 * no member has a value for is left out; when the pane is too narrow, columns go in DROP_ORDER
188 * and then the name gives way.
189 */
190export function layout(members: readonly Member[], width: number, now = 0): Layout {
191 const widths = {} as Record<ColumnKey, number>
192 for (const key of ORDER) {
193 widths[key] = Math.max(HEADINGS[key].length, ...members.map(member => value(member, key, now).length))
194 }
195 widths.age = Math.max(widths.age, 4)
196 let keys = ORDER.filter(
197 key => key === 'status' || key === 'name' || key === 'age' || members.some(member => value(member, key, now) !== ''),
198 )
199 for (const drop of DROP_ORDER) {
200 if (totalWidth(keys, widths) <= width) break
201 keys = keys.filter(key => key !== drop)
202 }
203 const over = totalWidth(keys, widths) - width
204 if (over > 0) widths.name = Math.max(MIN_NAME, widths.name - over)
205 return { keys, widths }
206}
207
208const pad = (key: ColumnKey, text: string, width: number): string =>
209 key === 'age' ? cut(text, width).padStart(width) : cut(text, width).padEnd(width)
210
211const STATUS_COLOR: Record<MemberStatus, string> = {
212 blocked: 'error',
213 working: 'suggestion',
214 done: 'success',
215 idle: 'inactive',
216 unknown: 'warning',
217 gone: 'subtle',
218}
219
220function paint(member: Member, key: ColumnKey): Pick<Cell, 'color' | 'bold' | 'dim'> {
221 const isGone = member.status === 'gone'
222 switch (key) {
223 case 'status':
224 return { color: STATUS_COLOR[member.status], bold: member.status === 'blocked' }
225 case 'name':
226 return { bold: !isGone, dim: isGone }
227 case 'where':
228 return member.isFocusedTab === true ? { color: 'claude', bold: true } : { color: 'inactive' }
229 case 'kind':
230 return { color: member.kind === 'claude' ? 'claude' : member.kind === 'codex' ? 'planMode' : 'inactive' }
231 case 'issue':
232 return { color: 'permission' }
233 case 'pane':
234 case 'dir':
235 case 'age':
236 // `subtle` proved too faint to read on a dark terminal; `inactive` is the quiet-but-legible key.
237 return { color: 'inactive' }
238 }
239}
240
241/** The header row for a layout: one bold heading per column shown. */
242export function headerCells(shape: Layout): Cell[] {
243 return shape.keys.map(key => ({ key, text: pad(key, HEADINGS[key], shape.widths[key]), color: 'text', bold: true }))
244}
245
246/** One member as painted cells, each padded to its column. */
247export function memberCells(member: Member, now: number, shape: Layout): Cell[] {
248 return shape.keys.map(key => ({ key, text: pad(key, value(member, key, now), shape.widths[key]), ...paint(member, key) }))
249}
250
251/** A row as the plain text it occupies: its cells joined by the column gap. */
252export function rowText(cells: readonly Cell[]): string {
253 return cells.map(cell => cell.text).join(' '.repeat(GAP))
254}
255
256/** The text between two cells of a drawn row. */
257export const COLUMN_GAP = ' '.repeat(GAP)
258
259/** The widths of the stream table's first two columns, shared by its header and every row. */
260export type StreamColumns = { name: number; phase: number }
261
262const STREAM_HEADINGS = { name: 'STREAM', phase: 'PHASE', ledger: 'LEDGER' } as const
263
264/** Chosen once per draw: the longest stream name and phase, the name giving way in a narrow pane. */
265export function streamColumns(streams: readonly StreamSummary[], width: number): StreamColumns {
266 const phase = Math.max(STREAM_HEADINGS.phase.length, ...streams.map(stream => stream.phase.length))
267 const natural = Math.max(STREAM_HEADINGS.name.length, ...streams.map(stream => stream.name.length))
268 // Leave room for the phase and at least a short ledger beside the name.
269 const room = width - GAP - phase - GAP - STREAM_HEADINGS.ledger.length
270 return { name: Math.max(MIN_NAME, Math.min(natural, room)), phase }
271}
272
273/** The stream table's header: STREAM, PHASE, LEDGER, padded to the columns. */
274export function streamHeader(cols: StreamColumns): string[] {
275 return [STREAM_HEADINGS.name.padEnd(cols.name), STREAM_HEADINGS.phase.padEnd(cols.phase), STREAM_HEADINGS.ledger]
276}
277
278/** A stream's name and phase, cut and padded to the columns. */
279export function streamLead(stream: StreamSummary, cols: StreamColumns): { name: string; phase: string } {
280 return { name: cut(stream.name, cols.name).padEnd(cols.name), phase: cut(stream.phase, cols.phase).padEnd(cols.phase) }
281}
282
283/** A horizontal rule across the pane, to set its sections apart. */
284export function divider(width: number): string {
285 return '─'.repeat(Math.max(0, width))
286}
287
288/** How a stream's ledger count is painted, by the status it counts. */
289export function countColor(status: string): string {
290 if (status === 'done') return 'success'
291 if (status === 'running') return 'suggestion'
292 if (status === 'blocked' || status === 'failed') return 'error'
293 return 'inactive'
294}
295
296/** A stream's ledger counts in display order: alphabetical, with running last. */
297export function streamCounts(stream: StreamSummary): { status: string; count: number }[] {
298 return Object.entries(stream.counts)
299 .sort(([a], [b]) => (a === 'running' ? 1 : b === 'running' ? -1 : a.localeCompare(b)))
300 .map(([status, count]) => ({ status, count }))
301}
302
303export function streamRow(stream: StreamSummary, width: number): string {
304 const order = Object.entries(stream.counts)
305 .sort(([a], [b]) => (a === 'running' ? 1 : b === 'running' ? -1 : a.localeCompare(b)))
306 const counts = order.map(([status, count]) => `${count} ${status}`)
307 return cut([stream.name, stream.phase, ...counts].filter(part => part !== '').join(' · '), width)
308}
309hooks/lib/sources.ts 147 lines1import type { StreamSummary } from '../../types'
2
3export type LiveAgent = {
4 paneId: string
5 kind: string
6 status: string
7 /** The terminal title the agent set, stripped of its status glyph. */
8 title: string
9 /** The herdr agent name, when the pane's agent has been given one. */
10 name: string
11 tabId: string
12 workspaceId: string
13 cwd: string
14}
15export type TabInfo = { tabId: string; label: string; isFocused: boolean }
16export type WorkspaceInfo = { workspaceId: string; label: string }
17export type RegistryRow = { name: string; kind: string; issue: string; paneId: string; parentPane: string }
18
19const text = (value: unknown): string => (typeof value === 'string' ? value : '')
20
21function isRecord(value: unknown): value is Record<string, unknown> {
22 return typeof value === 'object' && value !== null
23}
24
25export function parseAgents(stdout: string): LiveAgent[] | null {
26 let parsed: unknown
27 try {
28 parsed = JSON.parse(stdout)
29 } catch {
30 return null
31 }
32 if (!isRecord(parsed) || !isRecord(parsed.result)) return null
33 const agents = parsed.result.agents
34 if (!Array.isArray(agents)) return null
35
36 const live: LiveAgent[] = []
37 for (const agent of agents) {
38 if (!isRecord(agent)) continue
39 const paneId = text(agent.pane_id)
40 if (paneId === '') continue
41 live.push({
42 paneId,
43 kind: text(agent.agent),
44 status: text(agent.agent_status),
45 title: text(agent.terminal_title_stripped),
46 name: text(agent.name),
47 tabId: text(agent.tab_id),
48 workspaceId: text(agent.workspace_id),
49 cwd: text(agent.cwd),
50 })
51 }
52 return live
53}
54
55/** The rows of one herdr list result (`result.<key>`), or null when the output is not that. */
56function listOf(stdout: string, key: string): Record<string, unknown>[] | null {
57 let parsed: unknown
58 try {
59 parsed = JSON.parse(stdout)
60 } catch {
61 return null
62 }
63 if (!isRecord(parsed) || !isRecord(parsed.result)) return null
64 const rows = parsed.result[key]
65 return Array.isArray(rows) ? rows.filter(isRecord) : null
66}
67
68/** `herdr tab list`: each tab's id, the label herdr shows for it, and whether it is the focused one. */
69export function parseTabs(stdout: string): TabInfo[] {
70 return (listOf(stdout, 'tabs') ?? [])
71 .filter(tab => text(tab.tab_id) !== '')
72 .map(tab => ({ tabId: text(tab.tab_id), label: text(tab.label), isFocused: tab.focused === true }))
73}
74
75/** `herdr workspace list`: each workspace's id and the label herdr shows for it. */
76export function parseWorkspaces(stdout: string): WorkspaceInfo[] {
77 return (listOf(stdout, 'workspaces') ?? [])
78 .filter(workspace => text(workspace.workspace_id) !== '')
79 .map(workspace => ({ workspaceId: text(workspace.workspace_id), label: text(workspace.label) }))
80}
81
82/**
83 * Reads five fields of a registry row: name, kind, issue, pane_id, parent_pane. The plugin that
84 * writes the registry pins them in its test suite (`registry_row_carries_fields_crew_pane_reads`).
85 */
86export function parseRegistry(files: readonly string[]): RegistryRow[] {
87 const latest = new Map<string, RegistryRow>()
88 for (const file of files) {
89 for (const line of file.split('\n')) {
90 if (line.trim() === '') continue
91 let parsed: unknown
92 try {
93 parsed = JSON.parse(line)
94 } catch {
95 continue
96 }
97 if (!isRecord(parsed)) continue
98 const name = text(parsed.name)
99 const paneId = text(parsed.pane_id)
100 if (name === '' || paneId === '') continue
101 // Delete first so a re-recorded member moves to the end, keeping spawn order by latest row.
102 latest.delete(name)
103 latest.set(name, {
104 name,
105 kind: text(parsed.kind),
106 issue: text(parsed.issue),
107 paneId,
108 parentPane: text(parsed.parent_pane),
109 })
110 }
111 }
112 return [...latest.values()]
113}
114
115export function parseStream(source: string): StreamSummary | null {
116 const lines = source.split('\n')
117 if (lines[0]?.trim() !== '---') return null
118 const end = lines.indexOf('---', 1)
119 if (end === -1) return null
120
121 const front: Record<string, string> = {}
122 for (const line of lines.slice(1, end)) {
123 const match = /^([a-z_]+):\s*(.*)$/.exec(line)
124 const key = match?.[1]
125 const value = match?.[2]
126 if (key !== undefined && value !== undefined) front[key] = value.trim()
127 }
128 const name = front.stream
129 if (name === undefined || name === '') return null
130
131 const counts: Record<string, number> = {}
132 const running: string[] = []
133 const at = lines.findIndex(line => /^##\s+Ledger\s*$/.test(line))
134 if (at !== -1) {
135 for (const line of lines.slice(at + 1)) {
136 if (/^##\s/.test(line)) break
137 if (!line.startsWith('|')) continue
138 const cells = line.split('|').slice(1).map(cell => cell.trim())
139 const [task, , status] = cells
140 if (task === undefined || status === undefined || task === 'Task' || /^-+$/.test(task)) continue
141 counts[status] = (counts[status] ?? 0) + 1
142 if (status === 'running') running.push(task)
143 }
144 }
145 return { name, phase: front.phase ?? '', tracker: front.tracker ?? '', counts, running }
146}
147types/index.d.ts 50 lines1export type MemberStatus = 'idle' | 'working' | 'blocked' | 'done' | 'unknown' | 'gone'
2
3export type Member = {
4 name: string
5 /** The agent kind: `claude` or `codex`. */
6 kind: string
7 /** The tracker issue recorded at spawn; empty when none. */
8 issue: string
9 paneId: string
10 status: MemberStatus
11 /** When this mod first saw the member in its current status, ms since epoch. */
12 since: number
13 /** Where herdr shows it: workspace label, a slash, tab label (`DOCS/2`); the tab id when labels are unknown. */
14 where: string
15 /** True when its tab is the one focused in herdr. */
16 isFocusedTab: boolean
17 /** The last segment of its working directory; empty when unknown. */
18 dir: string
19}
20
21export type StreamSummary = {
22 name: string
23 phase: string
24 tracker: string
25 /** Ledger rows per status (`done`, `running`, ...). */
26 counts: Record<string, number>
27 /** Task names whose status is `running`. */
28 running: string[]
29}
30
31export type CrewState = {
32 members: Member[]
33 streams: StreamSummary[]
34 /** True: every live herdr agent. False: only members this session's pane spawned. */
35 showAll: boolean
36 /** A dim one-line reason when a source could not be read; null otherwise. */
37 error: string | null
38 /**
39 * Whether this session runs inside herdr. `unknown` until `session.start` has read the
40 * environment, so a pane drawn before then is not mistaken for one drawn outside.
41 */
42 environment: 'unknown' | 'inside' | 'outside'
43}
44
45declare module 'claude-code' {
46 interface PluginState {
47 'crew-pane': { crew: CrewState }
48 }
49}
50