SLOPSHOPPER

crew-pane

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

panecommandtoaststatusprocesstimer
v0.3.0MITupdated 2026-10-24DMokong/claude-plugins/plugins/crew-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crew-pane
│ ┃ Crew ✕ › fix the failing auth test and add an audit log call │ ┃ Not inside herdr. The crew pane only reads a │ ┃ herdr session it is running in. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /crew │ ⎿ crew-pane: Crew pane opened: 0 members. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Crew
Not inside herdr. The crew pane only reads a herdr session it is running in.
README

crew-pane

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.

  • A member that blocks or finishes raises a toast; optionally it is spoken aloud.
  • /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.

Needs

  • Claude Code with mod support (early access; tested on 2.1.291).
  • Running inside a Herdr pane (HERDR_ENV=1). Outside one it draws "Not inside herdr" and polls nothing.
  • For member names and issues: the 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.
  • For the stream table: docs/fable-streams/*/stream.md under the session's directory, as fable-conductor writes it. Without it the table is absent.

Options

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.

Turning it off

  • The 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.

Working on it

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.

Source 4 files
hooks/register.tsx 296 lines
1import { 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}
296
hooks/lib/crew.ts 309 lines
1import 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}
309
hooks/lib/sources.ts 147 lines
1import 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}
147
types/index.d.ts 50 lines
1export 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