SLOPSHOPPER

foreman

Live view of a foreman program: lanes, agents, stalls, what needs you, and memory

newpanebandguardcommandtoast
★ 1v0.1.0MITupdated 2026-10-06KodingDev/skills/mods/foreman
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · foreman
│ ┃ Foreman ✕ › fix the failing auth test and add an audit log call │ ┃ No program │ ┃ ⏺ Read(src/auth.ts) │ ┃ No lanes yet. Agents send them with the ⎿ Read 6 lines │ ┃ report tool. ⏺ 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 │ │ › /lanes │ ⎿ foreman: Lanes pane opened. No program yet: the first report pic │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Foreman
No program No lanes yet. Agents send them with the report tool.
README

foreman

A Claude Code mod that shows the lanes of a foreman program: what each lane does, what waits on you, and what each lane uses.

Agents write the content. The mod draws it and adds what it can see for itself: agent liveness, stalls, memory, CPU, and listening ports.

Install

claude plugin marketplace add KodingDev/skills
claude plugin install foreman@kodingdev

Use

  • /lanes opens the pane on the current program.
  • /lanes <program> switches the pane to another program. Until you switch, the pane follows the program of this session's latest report.
  • /lanes stop <lane> stops the agent of that lane.
  • /lanes close <lane> removes a lane from this session's report.

The band above the prompt lists what needs you, stalled agents, and permission denials. The status line shows the lane count, stalls, needs, and the ETA. Both hide when nothing is open.

The report tool

Agents call mcp__foreman__report after each change, with only what changed. The input schema in hooks/register.tsx describes each field:

  • program: the program id. Every session on one program sends the same id.
  • lanes: each lane that changed, with lane, title, and status. Optional: agent, worktree, summary, eta, todos, and resources. A sent lane replaces the lane of the same name.
  • closed: the names of lanes that are done.
  • needs: each decision or review that waits on you now. It replaces the last list. If it is left out, the last list stays.
  • eta: a rough estimate for the whole program.

The foreman skill tells its agents to send these reports.

What it observes

  • Ended agents: a lane that names an agent with no live run is drawn dim, with a hint to close it.
  • Stale reports: when open lanes have no report for 20 minutes, the system prompt asks the agent for one.
  • Stalls: a running subagent with no tool call for 10 minutes. A toast tells you once.
  • Usage per lane: the memory, CPU, and process count of every process whose working folder is inside the lane's worktree, with its children. The session's own processes do not count.
  • Ports: each TCP port that a lane process listens on, marked seen.
  • Memory: a toast when one process uses 24 GB or more.

Usage needs ps and lsof, so it shows on macOS and Linux only.

Storage

Each session writes its report to ~/.claude/plugins/data/foreman-kodingdev/programs/<program>/<session>.json. The pane merges the files of every live session on the program. A program with no file from the last day is hidden, and its files are removed on the next session start.

Source 8 files
hooks/register.tsx 661 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, Lane, SessionFile } from '../types'
5
6import { formatElapsed } from './elapsed'
7import { AGENT_STATUS_LABELS, countOf, laneCard } from './lane-card'
8import { CWD_ARGV, LISTEN_ARGV, PS_ARGV, sampleLanes } from './lane-processes'
9import { isProgramName, isSessionFile, mergeSessions } from './program-view'
10import { parseReport } from './report-input'
11
12const PANE = 'foreman'
13const PANE_TITLE = 'Foreman'
14const STALL_MS = 10 * 60 * 1000
15const HEAVY_GB = 24
16const TICK_MS = 5000
17const SAMPLE_MS = 15000
18const LIVE_STATUSES = new Set(['pending', 'running', 'waiting', 'idle'])
19const DATA_FOLDER_ID = 'foreman-kodingdev'
20const HEARTBEAT_MS = 60 * 1000
21const SEEN_THROTTLE_MS = 1000
22const PROGRAM_STALE_MS = 24 * 60 * 60 * 1000
23const MAIN_ID = 'main'
24const REPORT_STALE_MS = 20 * 60 * 1000
25const NUDGE = {
26  id: 'foreman:report-stale',
27  text: 'Your last foreman report is over 20 minutes old and lanes are open. Send mcp__foreman__report with what changed, and close lanes that are done.',
28  scope: 'session',
29} as const
30
31const program = atom({ plugin: 'foreman', key: 'program' } as const, null)
32const own = atom({ plugin: 'foreman', key: 'own' } as const, null)
33const view = atom({ plugin: 'foreman', key: 'view' } as const, null)
34const programs = atom({ plugin: 'foreman', key: 'programs' } as const, [])
35const waiting = atom({ plugin: 'foreman', key: 'waiting' } as const, [])
36const denials = atom({ plugin: 'foreman', key: 'denials' } as const, [])
37const tracking = atom({ plugin: 'foreman', key: 'tracking' } as const, {
38  lastSeen: {},
39  tokens: {},
40  stallToasted: [],
41  heavyToasted: [],
42  lastSampleAt: 0,
43  lastWriteAt: 0,
44  lastWriteKey: '',
45})
46
47const LANE_SCHEMA = {
48  type: 'object',
49  properties: {
50    lane: { type: 'string', description: 'Short lane name, for example "rust-shader-port".' },
51    title: { type: 'string', description: 'What the lane delivers, for example "PSY-412: battle-pass logos".' },
52    status: { type: 'string', description: 'A few words on where the lane stands now, in your own terms.' },
53    agent: { type: 'string', description: 'Name of the agent that works the lane.' },
54    worktree: { type: 'string', description: 'Absolute path the lane works in. The mod measures memory, CPU, and listening ports under it.' },
55    summary: { type: 'string', description: 'One or two sentences of markdown: what the lane does now and why.' },
56    eta: { type: 'string', description: 'Send on every lane: a rough time to done, a few characters, for example "~40m" or "~19:00". Update it when it moves.' },
57    todos: {
58      type: 'array',
59      description: 'The lane plan as small steps.',
60      items: {
61        type: 'object',
62        properties: {
63          text: { type: 'string' },
64          state: { type: 'string', enum: ['pending', 'active', 'done'] },
65          eta: { type: 'string', description: 'Rough time for this step, for example "~10m".' },
66        },
67        required: ['text', 'state'],
68      },
69    },
70    resources: {
71      type: 'array',
72      description: 'Things the lane owns that the user may want to open: a dev server, a preview URL, a PR, a log file.',
73      items: {
74        type: 'object',
75        properties: { label: { type: 'string' }, value: { type: 'string' } },
76        required: ['label', 'value'],
77      },
78    },
79  },
80  required: ['lane', 'title', 'status'],
81}
82
83const REPORT_SCHEMA = {
84  type: 'object',
85  properties: {
86    program: { type: 'string', description: 'The program id. Every session on one program sends the same id. One lower-case path segment.' },
87    lanes: { type: 'array', items: LANE_SCHEMA, description: 'Only the lanes that changed. Each one replaces the lane of the same name; other lanes stay.' },
88    closed: { type: 'array', items: { type: 'string' }, description: 'Names of lanes that are done. They leave the view.' },
89    needs: { type: 'array', items: { type: 'string' }, description: 'Each decision or review that waits on the user now, one line each. Replaces the last list; leave it out to keep it, send [] when nothing waits. Put later plans in a todo.' },
90    eta: { type: 'string', description: 'A rough time to done for the whole program, a few characters, for example "~3h". Status and plans go in lanes.' },
91  },
92  required: ['program'],
93}
94
95export const register: Register = on => {
96  on('session.start', async ($, e, next) => {
97    await $.command.register({
98      name: 'lanes',
99      description: 'Show the lanes of a program. Give a program id to switch, "stop <lane>" to stop its agent, or "close <lane>" to remove it.',
100      argumentHint: '[program | stop <lane> | close <lane>]',
101    })
102    await $.tool.register({
103      name: 'report',
104      description:
105        'Report the lanes of your program so the user can see them. Send only what changed: changed lanes, closed lanes, and needs or eta when they change.',
106      inputSchema: REPORT_SCHEMA,
107    })
108    await pruneSessionFiles($)
109    $.clock.every(TICK_MS, () => void tick($))
110
111    return next(e)
112  })
113
114  on('session.end', async ($, e, next) => {
115    const current = await read($, own)
116    if (current !== null) {
117      await saveSessionFile($, { ...current, updatedAt: 0 }, true)
118    }
119
120    return next(e)
121  })
122
123  on('command.run', { command: 'lanes' }, async ($, e) => {
124    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
125
126    if (verb === 'stop') {
127      return { text: await stopLane($, rest.join(' ')) }
128    }
129    if (verb === 'close') {
130      return { text: await closeLane($, rest.join(' ')) }
131    }
132    if (verb.length > 0) {
133      const answer = await selectProgram($, verb)
134      await $.ui.open({ id: PANE, title: PANE_TITLE })
135
136      return { text: answer }
137    }
138
139    await $.ui.open({ id: PANE, title: PANE_TITLE })
140    const selected = await read($, program)
141
142    return { text: selected === null ? 'Lanes pane opened. No program yet: the first report picks one.' : `Lanes pane opened on ${selected}.` }
143  })
144
145  on('tool.call', { tool: 'mcp__foreman__report' }, async ($, e) => {
146    const parsed = parseReport({ ...e })
147    if (!parsed.isValid) {
148      return { result: parsed.reason, text: parsed.reason, isError: true }
149    }
150
151    const { report } = parsed
152    const now = await $.clock.now()
153    const sessionId = await $.session.id()
154    const previous = await read($, own)
155    const isSameProgram = previous?.program === report.program
156    const kept = (isSameProgram ? previous.lanes : []).filter(lane => !report.closed.includes(lane.lane))
157    const sent: Lane[] = report.lanes.map(lane => {
158      const before = previous?.lanes.find(one => one.lane === lane.lane)
159      const statusSince = before?.status === lane.status ? before.statusSince : now
160
161      return { ...lane, statusSince, reportedAt: now }
162    })
163    const sentNames = new Set(sent.map(lane => lane.lane))
164    const lanes = [...kept.filter(lane => !sentNames.has(lane.lane)), ...sent]
165
166    const file: SessionFile = {
167      sessionId,
168      program: report.program,
169      updatedAt: now,
170      lanes,
171      needs: report.needs ?? (isSameProgram ? previous.needs : []),
172      eta: report.eta ?? (isSameProgram ? previous.eta : undefined),
173      agents: previous?.agents ?? [],
174      usage: previous?.usage ?? {},
175      totalRssGb: previous?.totalRssGb ?? 0,
176    }
177    await saveSessionFile($, file, true)
178
179    // The pane follows this session's own program until the user picks another one.
180    const selected = await read($, program)
181    const isFollowing = selected === null || selected === previous?.program
182    if (isFollowing) {
183      await update($, program, () => report.program)
184    }
185    await refreshView($, now)
186
187    const missingEta = lanes.filter(lane => lane.eta === undefined).map(lane => lane.lane)
188    const etaHint = missingEta.length === 0 ? '' : ` No eta yet: ${missingEta.join(', ')}.`
189    const text = `${report.program}: ${countOf(lanes.length, 'open lane')}, ${countOf(file.needs.length, 'need')}.${etaHint}`
190
191    return { result: text, text }
192  })
193
194  on('tool.call', async ($, e, next) => {
195    const agentId = e.agentId ?? MAIN_ID
196    const now = await $.clock.now()
197    const before = await read($, tracking)
198    const isSeenStale = now - (before.lastSeen[agentId] ?? 0) >= SEEN_THROTTLE_MS
199    if (isSeenStale) {
200      await update($, tracking, latest => ({ ...latest, lastSeen: { ...latest.lastSeen, [agentId]: now } }))
201    }
202
203    const isSubagentQuestion = e.tool === 'AskUserQuestion' && e.agentId !== undefined
204    const question = isSubagentQuestion ? await describeQuestion($, agentId, e.questions) : null
205    if (question !== null) {
206      await update($, waiting, list => [...list, question])
207    }
208
209    const ran = await next(e)
210
211    if (question !== null) {
212      await update($, waiting, list => list.filter(one => one !== question))
213    }
214    if (ran.deny !== undefined) {
215      const denial = { tool: String(e.tool), reason: ran.deny, at: now }
216      await update($, denials, list => [...list, denial].slice(-20))
217    }
218
219    return ran
220  })
221
222  on('turn.complete', async ($, e, next) => {
223    const agentId = e.agentId ?? MAIN_ID
224    const { usage } = e
225    if (usage !== undefined) {
226      const spent = usage.input_tokens + usage.output_tokens + usage.cache_creation_input_tokens
227      await update($, tracking, before => ({
228        ...before,
229        tokens: { ...before.tokens, [agentId]: (before.tokens[agentId] ?? 0) + spent },
230      }))
231    }
232
233    return next(e)
234  })
235
236  on('prompt.compose', async ($, e, next) => {
237    const composed = await next(e)
238    const current = await read($, own)
239    const now = await $.clock.now()
240    const lastReportAt = Math.max(0, ...(current?.lanes ?? []).map(lane => lane.reportedAt))
241    const isStale = current !== null && current.lanes.length > 0 && now - lastReportAt > REPORT_STALE_MS
242    if (!isStale) {
243      return composed
244    }
245
246    return { sections: [...composed.sections, NUDGE] }
247  })
248
249  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
250    const current = await read($, view)
251    const asked = await read($, waiting)
252    const denied = await read($, denials)
253    const needs = [...(current?.needs ?? []), ...asked]
254    const stalled = (current?.agents ?? []).filter(agent => agent.isStalled)
255    const isQuiet = needs.length === 0 && stalled.length === 0 && denied.length === 0
256
257    if (e.props.hasSurvey || isQuiet) {
258      return next(e)
259    }
260
261    const { Box, Button, Text } = $.ui.resolve(e)
262    const lastDenial = denied.at(-1)
263    const items = [
264      ...needs.map(need => `needs you: ${need}`),
265      ...stalled.map(agent => `stalled: ${agent.name}`),
266      ...(lastDenial === undefined ? [] : [`${countOf(denied.length, 'permission denial')} (last: ${lastDenial.tool})`]),
267    ]
268    const hiddenCount = items.length - 3
269
270    return (
271      <Box flexDirection="column">
272        {items.slice(0, 3).map(item => (
273          <Text color="yellow" wrap="truncate-end">
274            {item}
275          </Text>
276        ))}
277        <Box>
278          {hiddenCount > 0 && <Text dimColor>+{hiddenCount} more </Text>}
279          <Button key="open" label="Open lanes" onPress={() => void $.ui.open({ id: PANE, title: PANE_TITLE })} />
280          {denied.length > 0 && <Button key="clear" label="Clear denials" onPress={() => update($, denials, () => [])} />}
281        </Box>
282      </Box>
283    )
284  })
285
286  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
287    const elements = $.ui.resolve(e)
288    const { Box, Button, Text } = elements
289    const now = await $.clock.now()
290    const selected = await read($, program)
291    const current = await read($, view)
292    const known = await read($, programs)
293    const asked = await read($, waiting)
294    const denied = await read($, denials)
295
296    const others = known.filter(name => name !== selected)
297    const lanes = current?.lanes ?? []
298    const agents = current?.agents ?? []
299    const laneAgents = new Set(lanes.map(lane => lane.agent))
300    const looseAgents = agents.filter(agent => agent.id !== MAIN_ID && !laneAgents.has(agent.name))
301    const needs = [...(current?.needs ?? []), ...asked]
302    const labelWidth = Math.max(0, ...lanes.flatMap(lane => [...lane.resources, ...(current?.usage[lane.lane]?.ports ?? [])].map(resource => resource.label.length)))
303
304    return (
305      <Box flexDirection="column" gap={1}>
306        <Box justifyContent="space-between">
307          <Text bold wrap="truncate-end">
308            {selected ?? 'No program'}
309          </Text>
310          <Text dimColor>
311            {current !== null && countOf(current.sessionCount, 'session')}
312            {current?.eta !== undefined && <Text color="cyan">  ETA {current.eta}</Text>}
313          </Text>
314        </Box>
315        {others.length > 0 && (
316          <Box gap={1}>
317            <Text dimColor>switch</Text>
318            {others.map(name => (
319              <Button key={`program-${name}`} label={name} plain onPress={() => void selectProgram($, name)} />
320            ))}
321          </Box>
322        )}
323
324        {needs.length > 0 && (
325          <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
326            <Text bold color="yellow">
327              Needs you
328            </Text>
329            {needs.map(need => (
330              <Text wrap="truncate-end">{need}</Text>
331            ))}
332          </Box>
333        )}
334
335        {lanes.length === 0 && <Text dimColor>No lanes yet. Agents send them with the report tool.</Text>}
336        {lanes.map(lane => {
337          const worker = agents.find(agent => agent.name === lane.agent)
338
339          return laneCard(elements, lane, worker, current?.usage[lane.lane], labelWidth, now)
340        })}
341
342        {looseAgents.length > 0 && (
343          <Box flexDirection="column">
344            <Text bold>Other agents</Text>
345            {looseAgents.map(agent => (
346              <Text color={agent.isStalled ? 'red' : undefined} dimColor={!agent.isStalled} wrap="truncate-end">
347                {agent.name}  {agent.isStalled ? 'stalled' : (AGENT_STATUS_LABELS[agent.status] ?? agent.status)}, active {formatElapsed(now, agent.lastSeen)} ago
348              </Text>
349            ))}
350          </Box>
351        )}
352
353        {denied.length > 0 && (
354          <Box flexDirection="column">
355            <Text bold color="red">
356              Permission denials
357            </Text>
358            {denied.slice(-5).map(denial => (
359              <Text wrap="truncate-end">
360                <Text color="red">{denial.tool}</Text>
361                <Text dimColor>
362                  {'  '}
363                  {formatElapsed(now, denial.at)} ago  {denial.reason}
364                </Text>
365              </Text>
366            ))}
367          </Box>
368        )}
369
370        {current !== null && current.totalRssGb > 0 && <Text dimColor>Machine memory in use: {current.totalRssGb.toFixed(1)} GB</Text>}
371      </Box>
372    )
373  })
374}
375
376const tick = async ($: EngineInterface) => {
377  const now = await $.clock.now()
378  const before = await read($, tracking)
379  const listed = await $.agent.list()
380
381  const live = listed.filter(agent => LIVE_STATUSES.has(agent.status))
382  const lastSeen = { ...before.lastSeen }
383  for (const agent of live) {
384    lastSeen[agent.id] = lastSeen[agent.id] ?? now
385  }
386
387  const mainRow: AgentRow = {
388    id: MAIN_ID,
389    name: MAIN_ID,
390    status: 'running',
391    lastSeen: lastSeen[MAIN_ID] ?? now,
392    isStalled: false,
393    tokenCount: before.tokens[MAIN_ID] ?? 0,
394  }
395  const subagents: AgentRow[] = live.map(agent => {
396    const seen = lastSeen[agent.id] ?? now
397    const row: AgentRow = {
398      id: agent.id,
399      name: agent.name ?? agent.description,
400      status: agent.status,
401      lastSeen: seen,
402      isStalled: agent.status === 'running' && now - seen > STALL_MS,
403      tokenCount: before.tokens[agent.id] ?? 0,
404    }
405
406    return row
407  })
408
409  const agents = [mainRow, ...subagents]
410  const newlyStalled = agents.filter(agent => agent.isStalled && !before.stallToasted.includes(agent.id))
411  for (const agent of newlyStalled) {
412    $.ui.toast(`lanes: ${agent.name} has made no tool call for ${formatElapsed(now, agent.lastSeen)}`)
413  }
414  const stallToasted = agents.filter(agent => agent.isStalled).map(agent => agent.id)
415
416  const current = await read($, own)
417  const shown = await read($, view)
418  const isSampleDue = now - before.lastSampleAt >= SAMPLE_MS
419  const sampled = isSampleDue ? await sampleProcesses($, shown?.lanes ?? current?.lanes ?? []) : null
420
421  const heaviest = sampled?.heaviest ?? null
422  const heavyKey = heaviest === null ? '' : `${heaviest.pid}`
423  const isNewHeavy = heaviest !== null && heaviest.rssGb >= HEAVY_GB && !before.heavyToasted.includes(heavyKey)
424  if (isNewHeavy) {
425    const where = heaviest.lane === null ? `process ${heaviest.pid}` : `lane ${heaviest.lane} (process ${heaviest.pid})`
426    $.ui.toast(`lanes: ${where} uses ${heaviest.rssGb.toFixed(0)} GB of memory`)
427  }
428
429  await update($, tracking, latest => ({
430    ...latest,
431    lastSeen,
432    stallToasted,
433    heavyToasted: isNewHeavy ? [...latest.heavyToasted, heavyKey].slice(-50) : latest.heavyToasted,
434    lastSampleAt: isSampleDue ? now : latest.lastSampleAt,
435  }))
436
437  if (current !== null) {
438    const file: SessionFile = {
439      ...current,
440      updatedAt: now,
441      agents,
442      usage: sampled?.lanes ?? current.usage,
443      totalRssGb: sampled?.totalRssGb ?? current.totalRssGb,
444    }
445    await saveSessionFile($, file, false)
446  }
447
448  await refreshView($, now, agents)
449  $.ui.status(await statusLine($))
450}
451
452const refreshView = async ($: EngineInterface, now: number, ownAgents?: AgentRow[]) => {
453  const selected = await read($, program)
454  const known = await listPrograms($)
455  await update($, programs, () => known)
456
457  if (selected === null) {
458    const agents = ownAgents ?? []
459    await update($, view, () => (agents.length === 0 ? null : { program: '', sessionCount: 1, lanes: [], needs: [], agents, usage: {}, totalRssGb: 0 }))
460    return
461  }
462
463  const files = await readProgramFiles($, selected)
464  await update($, view, () => mergeSessions(selected, files, now))
465}
466
467const sampleProcesses = async ($: EngineInterface, lanes: Lane[]) => {
468  const worktrees = Object.fromEntries(lanes.flatMap(lane => (lane.worktree === undefined ? [] : [[lane.lane, lane.worktree] as const])))
469  const runs = await Promise.all([$.process.run(PS_ARGV), $.process.run(CWD_ARGV), $.process.run(LISTEN_ARGV)]).catch(() => {
470    // A host without ps or lsof (Windows) cannot start them. The pane then shows no usage lines.
471    return null
472  })
473  if (runs === null || runs[0].exitCode !== 0) {
474    return null
475  }
476
477  return sampleLanes(runs[0].stdout, runs[1].stdout, runs[2].stdout, worktrees)
478}
479
480const selectProgram = async ($: EngineInterface, name: string) => {
481  if (!isProgramName(name)) {
482    return `"${name}" is not a program id. Program ids are one lower-case path segment.`
483  }
484
485  const known = await listPrograms($)
486  if (!known.includes(name)) {
487    return `No program "${name}". Known programs: ${known.join(', ') || 'none yet'}.`
488  }
489
490  await update($, program, () => name)
491  await refreshView($, await $.clock.now())
492
493  return `Lanes pane now shows ${name}.`
494}
495
496const stopLane = async ($: EngineInterface, laneName: string) => {
497  const current = await read($, view)
498  const lane = current?.lanes.find(one => one.lane === laneName)
499  if (lane === undefined) {
500    return `No lane "${laneName}" in this program.`
501  }
502  if (lane.agent === undefined) {
503    return `Lane ${laneName} reports no agent, so there is nothing to stop.`
504  }
505
506  const stopped = await $.tool.call({ tool: 'TaskStop', task_id: lane.agent })
507  if (stopped.deny !== undefined) {
508    return `Could not stop ${lane.agent}: ${stopped.deny}`
509  }
510
511  return `The user stopped agent ${lane.agent} on lane ${laneName}. Re-plan that lane.`
512}
513
514const closeLane = async ($: EngineInterface, laneName: string) => {
515  const current = await read($, own)
516  const isOwnLane = current?.lanes.some(lane => lane.lane === laneName) === true
517  if (current === null || !isOwnLane) {
518    return `No lane "${laneName}" in this session's report.`
519  }
520
521  const now = await $.clock.now()
522  const file: SessionFile = { ...current, updatedAt: now, lanes: current.lanes.filter(lane => lane.lane !== laneName) }
523  await saveSessionFile($, file, true)
524  await refreshView($, now)
525
526  return `The user closed lane ${laneName}. Leave it out of later reports.`
527}
528
529const statusLine = async ($: EngineInterface) => {
530  const selected = await read($, program)
531  const current = await read($, view)
532  const asked = await read($, waiting)
533  const hasSubagents = current?.agents.some(agent => agent.id !== MAIN_ID) === true
534  const isIdle = current === null || (current.lanes.length === 0 && !hasSubagents && current.needs.length === 0 && asked.length === 0)
535  if (isIdle) {
536    return undefined
537  }
538
539  const stalledCount = current.agents.filter(agent => agent.isStalled).length
540  const needCount = current.needs.length + asked.length
541  const parts = [
542    `${selected ?? 'no program'}: ${countOf(current.lanes.length, 'lane')}`,
543    stalledCount > 0 ? `${stalledCount} stalled` : '',
544    needCount > 0 ? `${needCount} need you` : '',
545    current.eta === undefined ? '' : `ETA ${current.eta}`,
546  ]
547
548  return parts.filter(part => part.length > 0).join(', ')
549}
550
551const describeQuestion = async ($: EngineInterface, agentId: string, asked: Array<{ question: string }>) => {
552  const listed = await $.agent.list()
553  const agent = listed.find(one => one.id === agentId)
554  const who = agent?.name ?? agent?.description ?? 'an agent'
555  const first = asked[0]?.question ?? 'a question'
556
557  return `${who} asks: ${first}`
558}
559
560const dataFolder = async ($: EngineInterface) => {
561  const fromPlugin = await $.env.get('CLAUDE_PLUGIN_DATA')
562  if (fromPlugin !== undefined) {
563    return fromPlugin
564  }
565
566  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
567
568  return `${config}/plugins/data/${DATA_FOLDER_ID}`
569}
570
571const listPrograms = async ($: EngineInterface) => {
572  const root = `${await dataFolder($)}/programs`
573  const hasRoot = await $.fs.exists(root)
574  if (!hasRoot) {
575    return []
576  }
577
578  const now = await $.clock.now()
579  const entries = await $.fs.list(root)
580  const folders = entries.filter(entry => entry.kind === 'dir' && isProgramName(entry.name))
581  const active = await Promise.all(
582    folders.map(async folder => {
583      const files = await $.fs.list(`${root}/${folder.name}`)
584      const isActive = files.some(file => now - file.mtimeMs < PROGRAM_STALE_MS)
585
586      return isActive ? [folder.name] : []
587    }),
588  )
589
590  return active.flat().sort()
591}
592
593const pruneSessionFiles = async ($: EngineInterface) => {
594  const root = `${await dataFolder($)}/programs`
595  const hasRoot = await $.fs.exists(root)
596  if (!hasRoot) {
597    return
598  }
599
600  const now = await $.clock.now()
601  const folders = (await $.fs.list(root)).filter(entry => entry.kind === 'dir')
602  const listed = await Promise.all(
603    folders.map(async folder => {
604      const files = await $.fs.list(`${root}/${folder.name}`)
605
606      return files.filter(file => file.name.endsWith('.json') && now - file.mtimeMs >= PROGRAM_STALE_MS).map(file => `${root}/${folder.name}/${file.name}`)
607    }),
608  )
609  const stale = listed.flat()
610  if (stale.length === 0) {
611    return
612  }
613
614  // $.fs cannot delete. A host without rm keeps the files, and listPrograms still hides them.
615  await $.process.run(['rm', '-f', ...stale]).catch(() => null)
616}
617
618const readProgramFiles = async ($: EngineInterface, name: string) => {
619  const folder = `${await dataFolder($)}/programs/${name}`
620  const hasFolder = await $.fs.exists(folder)
621  if (!hasFolder) {
622    return []
623  }
624
625  const entries = await $.fs.list(folder)
626  const texts = await Promise.all(entries.filter(entry => entry.name.endsWith('.json')).map(entry => $.fs.read(`${folder}/${entry.name}`)))
627
628  return texts.flatMap(text => {
629    const parsed = parseJson(text)
630    return isSessionFile(parsed) ? [parsed] : []
631  })
632}
633
634/**
635 * Keep the session's report in state and on disk. Unforced, the disk write happens only when the
636 * content changed or the heartbeat is due, so other sessions see this one as live.
637 */
638const saveSessionFile = async ($: EngineInterface, file: SessionFile, isForced: boolean) => {
639  await update($, own, () => file)
640
641  const key = JSON.stringify({ ...file, updatedAt: 0 })
642  const before = await read($, tracking)
643  const isDue = isForced || key !== before.lastWriteKey || file.updatedAt - before.lastWriteAt >= HEARTBEAT_MS
644  if (!isDue) {
645    return
646  }
647
648  const folder = `${await dataFolder($)}/programs/${file.program}`
649  await $.fs.write(`${folder}/${file.sessionId}.json`, JSON.stringify(file))
650  await update($, tracking, latest => ({ ...latest, lastWriteAt: file.updatedAt, lastWriteKey: key }))
651}
652
653const parseJson = (text: string): unknown => {
654  try {
655    return JSON.parse(text)
656  } catch {
657    // Another session can be mid-write. Its file is read again on the next tick.
658    return null
659  }
660}
661
hooks/elapsed.ts 14 lines
1const MS_PER_MINUTE = 60 * 1000
2
3/**
4 * Format the time from `then` to `now` as `12m` or `3h05m`.
5 */
6export const formatElapsed = (now: number, then: number) => {
7  const minutes = Math.max(0, Math.round((now - then) / MS_PER_MINUTE))
8  if (minutes < 60) {
9    return `${minutes}m`
10  }
11
12  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
13}
14
hooks/lane-card.tsx 120 lines
1import type { ElementTable } from 'claude-code'
2
3import type { AgentRow, Lane, LaneUsage, Todo } from '../types'
4
5import { formatElapsed } from './elapsed'
6import { progressBar } from './progress-bar'
7
8/**
9 * The elements a card draws with. Every surface has them.
10 */
11export type CardElements = Pick<ElementTable, 'Box' | 'Markdown' | 'Text'>
12
13const PROGRESS_WIDTH = 20
14/**
15 * Plain words for the agent statuses that read as stuck. A teammate is `idle` while a background job runs.
16 */
17export const AGENT_STATUS_LABELS: Record<string, string> = { idle: 'waiting', running: 'working' }
18const TODO_MARKS = { done: '[x]', active: '>', pending: '-' } as const satisfies Record<Todo['state'], string>
19
20/**
21 * Format a count with its noun, singular for one.
22 */
23export const countOf = (count: number, noun: string, plural = `${noun}s`) => `${count} ${count === 1 ? noun : plural}`
24
25const formatTokens = (count: number) => (count >= 1_000_000 ? `${(count / 1_000_000).toFixed(1)}M` : `${Math.round(count / 1000)}k`)
26
27/**
28 * Draw one lane as a bordered card: header, status, progress, summary, open todos, resources, and a
29 * usage footer. The border is red while the lane's agent is stalled. A lane that names an agent with
30 * no live run is drawn dim as ended.
31 *
32 * @param labelWidth the width that aligns resource labels across every card
33 */
34export const laneCard = (
35  { Box, Markdown, Text }: CardElements,
36  lane: Lane,
37  worker: AgentRow | undefined,
38  used: LaneUsage | undefined,
39  labelWidth: number,
40  now: number,
41) => {
42  const doneCount = lane.todos.filter(todo => todo.state === 'done').length
43  const openTodos = lane.todos.filter(todo => todo.state !== 'done')
44  const resources = [...lane.resources, ...(used?.ports ?? [])]
45  const isStalled = worker?.isStalled === true
46  const hasEnded = lane.agent !== undefined && worker === undefined
47  const workerState = isStalled ? 'stalled' : (AGENT_STATUS_LABELS[worker?.status ?? ''] ?? worker?.status)
48  const footer = [
49    hasEnded ? `${lane.agent} ended; /lanes close ${lane.lane} removes the lane` : '',
50    worker === undefined ? '' : `${worker.name} ${workerState}, active ${formatElapsed(now, worker.lastSeen)} ago`,
51    used === undefined ? '' : `${used.rssGb.toFixed(1)} GB  ${Math.round(used.cpuPercent)}% CPU  ${countOf(used.processCount, 'process', 'processes')}`,
52    worker === undefined || worker.tokenCount === 0 ? '' : `${formatTokens(worker.tokenCount)} tokens`,
53  ].filter(part => part.length > 0)
54
55  return (
56    <Box key={`lane-${lane.lane}`} flexDirection="column" borderStyle="round" borderColor={isStalled ? 'red' : 'gray'} borderDimColor={hasEnded} paddingX={1}>
57      <Box justifyContent="space-between">
58        <Text wrap="truncate-end">
59          <Text bold>{lane.lane}</Text>
60          <Text dimColor>  {lane.title}</Text>
61        </Text>
62        {lane.eta !== undefined && <Text color="cyan">{lane.eta}</Text>}
63      </Box>
64      <Text wrap="truncate-end">
65        <Text color={isStalled ? 'red' : 'green'} dimColor={hasEnded}>
66          {lane.status}
67        </Text>
68        <Text dimColor>
69          {'  '}
70          for {formatElapsed(now, lane.statusSince)}, updated {formatElapsed(now, lane.reportedAt)} ago
71        </Text>
72      </Text>
73      {lane.todos.length > 0 && (
74        <Text>
75          <Text color="green">{progressBar(doneCount, lane.todos.length, PROGRESS_WIDTH)}</Text>
76          <Text dimColor>
77            {'  '}
78            {doneCount}/{lane.todos.length} done
79          </Text>
80        </Text>
81      )}
82      {lane.summary !== undefined && (
83        <Box marginTop={1}>
84          <Markdown key={`summary-${lane.lane}`} text={lane.summary} />
85        </Box>
86      )}
87      {openTodos.length > 0 && (
88        <Box flexDirection="column" marginTop={1}>
89          {openTodos.map(todo => (
90            <Box justifyContent="space-between">
91              <Text dimColor={todo.state === 'pending'} wrap="truncate-end">
92                <Text color={todo.state === 'active' ? 'cyan' : undefined}>{TODO_MARKS[todo.state]}</Text> {todo.text}
93              </Text>
94              {todo.eta !== undefined && <Text dimColor>{todo.eta}</Text>}
95            </Box>
96          ))}
97        </Box>
98      )}
99      {resources.length > 0 && (
100        <Box flexDirection="column" marginTop={1}>
101          {resources.map(resource => (
102            <Text wrap="truncate-end">
103              <Text dimColor>{resource.label.padEnd(labelWidth)}  </Text>
104              {resource.value}
105              {resource.isObserved === true && <Text dimColor>  seen</Text>}
106            </Text>
107          ))}
108        </Box>
109      )}
110      {footer.length > 0 && (
111        <Box marginTop={1}>
112          <Text dimColor wrap="truncate-end">
113            {footer.join('  |  ')}
114          </Text>
115        </Box>
116      )}
117    </Box>
118  )
119}
120
hooks/lane-processes.ts 174 lines
1import type { LaneResource, LaneUsage } from '../types'
2
3type Process = {
4  pid: number
5  ppid: number
6  rssKb: number
7  cpuPercent: number
8  command: string
9}
10
11/**
12 * Per-lane usage, the machine total, and the single largest process.
13 */
14export type ProcessSample = {
15  lanes: Record<string, LaneUsage>
16  totalRssGb: number
17  heaviest: { pid: number; rssGb: number; lane: string | null } | null
18}
19
20const PS_LINE = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(.*)$/
21const LISTEN_PORT = /:(\d+)$/
22const KB_PER_GB = 1024 * 1024
23
24/**
25 * The `ps` arguments whose output `sampleLanes` reads.
26 */
27export const PS_ARGV = ['ps', '-A', '-o', 'pid=,ppid=,rss=,%cpu=,comm='] as const
28
29/**
30 * The `lsof` arguments for the working folder of every process.
31 */
32export const CWD_ARGV = ['lsof', '-n', '-d', 'cwd', '-Fpn'] as const
33
34/**
35 * The `lsof` arguments for every listening TCP socket.
36 */
37export const LISTEN_ARGV = ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-Fpn'] as const
38
39const parseProcesses = (psOutput: string) => {
40  const processes: Process[] = []
41
42  for (const line of psOutput.split('\n')) {
43    const match = PS_LINE.exec(line)
44    if (match === null) {
45      continue
46    }
47
48    processes.push({
49      pid: Number(match[1]),
50      ppid: Number(match[2]),
51      rssKb: Number(match[3]),
52      cpuPercent: Number(match[4]),
53      command: (match[5] ?? '').split('/').pop() ?? '',
54    })
55  }
56
57  return processes
58}
59
60/**
61 * Read `lsof -F pn` output into the name lines of each process.
62 */
63const parseNamesByPid = (lsofOutput: string) => {
64  const names = new Map<number, string[]>()
65  let pid = 0
66
67  for (const line of lsofOutput.split('\n')) {
68    if (line.startsWith('p')) {
69      pid = Number(line.slice(1))
70      continue
71    }
72    if (line.startsWith('n') && pid > 0) {
73      names.set(pid, [...(names.get(pid) ?? []), line.slice(1)])
74    }
75  }
76
77  return names
78}
79
80const isInside = (folder: string, root: string) => folder === root || folder.startsWith(`${root}/`)
81
82/**
83 * The pids of the session that runs the sample: every ancestor of the `ps` process. The session
84 * often starts inside a worktree, and its own memory and ports must not count for that lane.
85 */
86const sessionPids = (processes: Process[]) => {
87  const parentOf = new Map(processes.map(process => [process.pid, process.ppid]))
88  const pids = new Set<number>()
89
90  for (const sampler of processes.filter(process => process.command === 'ps')) {
91    let pid = sampler.pid
92    while (pid > 1 && !pids.has(pid)) {
93      pids.add(pid)
94      pid = parentOf.get(pid) ?? 0
95    }
96  }
97
98  return pids
99}
100
101const assignLanes = (processes: Process[], cwdOutput: string, worktrees: Record<string, string>) => {
102  const folders = parseNamesByPid(cwdOutput)
103  const roots = Object.entries(worktrees)
104  const skipped = sessionPids(processes)
105  const laneOfPid = new Map<number, string>()
106
107  for (const process of processes.filter(one => !skipped.has(one.pid))) {
108    const folder = folders.get(process.pid)?.[0]
109    const owner = folder === undefined ? undefined : roots.find(([, root]) => isInside(folder, root))
110    if (owner !== undefined) {
111      laneOfPid.set(process.pid, owner[0])
112    }
113  }
114
115  // A child inherits its parent's lane, so a server that changes folder still counts. Repeat until stable.
116  let hasChanged = true
117  while (hasChanged) {
118    hasChanged = false
119    for (const process of processes) {
120      const parentLane = laneOfPid.get(process.ppid)
121      if (parentLane !== undefined && !laneOfPid.has(process.pid) && !skipped.has(process.pid)) {
122        laneOfPid.set(process.pid, parentLane)
123        hasChanged = true
124      }
125    }
126  }
127
128  return laneOfPid
129}
130
131/**
132 * Assign each process to the lane whose worktree holds its working folder, with every descendant of
133 * that process, then total memory and CPU per lane and list the ports each lane listens on.
134 *
135 * @param worktrees lane name to absolute worktree path
136 */
137export const sampleLanes = (psOutput: string, cwdOutput: string, listenOutput: string, worktrees: Record<string, string>) => {
138  const processes = parseProcesses(psOutput)
139  const laneOfPid = assignLanes(processes, cwdOutput, worktrees)
140  const listening = parseNamesByPid(listenOutput)
141
142  const lanes: Record<string, LaneUsage> = {}
143  let totalKb = 0
144  let heaviest: ProcessSample['heaviest'] = null
145
146  for (const process of processes) {
147    totalKb += process.rssKb
148
149    const rssGb = process.rssKb / KB_PER_GB
150    const lane = laneOfPid.get(process.pid) ?? null
151    if (heaviest === null || rssGb > heaviest.rssGb) {
152      heaviest = { pid: process.pid, rssGb, lane }
153    }
154    if (lane === null) {
155      continue
156    }
157
158    const ports = [...new Set((listening.get(process.pid) ?? []).map(name => LISTEN_PORT.exec(name)?.[1]).filter(port => port !== undefined))]
159    const found: LaneResource[] = ports.map(port => ({ label: process.command, value: `localhost:${port}`, isObserved: true }))
160    const before = lanes[lane] ?? { rssGb: 0, cpuPercent: 0, processCount: 0, ports: [] }
161
162    lanes[lane] = {
163      rssGb: before.rssGb + rssGb,
164      cpuPercent: before.cpuPercent + process.cpuPercent,
165      processCount: before.processCount + 1,
166      ports: [...before.ports, ...found],
167    }
168  }
169
170  const sample: ProcessSample = { lanes, totalRssGb: totalKb / KB_PER_GB, heaviest }
171
172  return sample
173}
174
hooks/program-view.ts 60 lines
1import type { ProgramView, SessionFile } from '../types'
2
3/**
4 * A session that has not written its file for this long no longer counts toward its program.
5 */
6export const SESSION_STALE_MS = 2 * 60 * 1000
7
8const PROGRAM_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/
9
10/**
11 * Whether `name` is a usable program name. Must be one path segment in lower case.
12 */
13export const isProgramName = (name: string) => PROGRAM_NAME.test(name)
14
15/**
16 * Whether a parsed session file has the fields the view reads. Files from other sessions are untrusted JSON.
17 */
18export const isSessionFile = (value: unknown): value is SessionFile => {
19  if (typeof value !== 'object' || value === null) {
20    return false
21  }
22
23  const file: Record<string, unknown> = { ...value }
24
25  return (
26    typeof file.sessionId === 'string' &&
27    typeof file.program === 'string' &&
28    typeof file.updatedAt === 'number' &&
29    Array.isArray(file.lanes) &&
30    Array.isArray(file.needs) &&
31    Array.isArray(file.agents)
32  )
33}
34
35/**
36 * Merge the files of every live session on one program into one view. When two sessions report the
37 * same lane, the newer report wins.
38 */
39export const mergeSessions = (program: string, files: SessionFile[], now: number) => {
40  const live = files
41    .filter(file => file.program === program && now - file.updatedAt < SESSION_STALE_MS)
42    .sort((a, b) => a.updatedAt - b.updatedAt)
43
44  const lanesByName = new Map(live.flatMap(file => file.lanes.map(lane => [lane.lane, lane] as const)))
45  const newest = live.at(-1)
46
47  const view: ProgramView = {
48    program,
49    sessionCount: live.length,
50    lanes: [...lanesByName.values()],
51    needs: live.flatMap(file => file.needs),
52    eta: newest?.eta,
53    agents: live.flatMap(file => file.agents),
54    usage: newest?.usage ?? {},
55    totalRssGb: newest?.totalRssGb ?? 0,
56  }
57
58  return view
59}
60
hooks/report-input.ts 112 lines
1import type { Lane, LaneResource, Todo } from '../types'
2
3import { isProgramName } from './program-view'
4
5/**
6 * A lane as an agent reports it, before the mod adds when its status changed and when it was sent.
7 */
8export type ReportedLane = Omit<Lane, 'statusSince' | 'reportedAt'>
9
10/**
11 * A report as an agent sends it.
12 */
13export type Report = {
14  program: string
15  lanes: ReportedLane[]
16  closed: string[]
17  needs?: string[]
18  eta?: string
19}
20
21/**
22 * The result of reading a report call's arguments.
23 */
24export type ReportParse = { isValid: true; report: Report } | { isValid: false; reason: string }
25
26const TODO_STATES = ['pending', 'active', 'done'] as const satisfies readonly Todo['state'][]
27
28const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
29
30const optionalText = (value: unknown) => (typeof value === 'string' && value.length > 0 ? value : undefined)
31
32const listOf = <T,>(value: unknown, read: (item: unknown) => T | null) => (Array.isArray(value) ? value.map(read).filter(item => item !== null) : [])
33
34const readTodo = (value: unknown) => {
35  if (!isRecord(value) || typeof value.text !== 'string') {
36    return null
37  }
38
39  const todo: Todo = {
40    text: value.text,
41    state: TODO_STATES.find(state => state === value.state) ?? 'pending',
42    eta: optionalText(value.eta),
43  }
44
45  return todo
46}
47
48const readResource = (value: unknown) => {
49  if (!isRecord(value) || typeof value.label !== 'string' || typeof value.value !== 'string') {
50    return null
51  }
52
53  const resource: LaneResource = { label: value.label, value: value.value }
54
55  return resource
56}
57
58const readLane = (value: unknown) => {
59  if (!isRecord(value)) {
60    return null
61  }
62
63  const { lane, title, status } = value
64  if (typeof lane !== 'string' || typeof title !== 'string' || typeof status !== 'string') {
65    return null
66  }
67
68  const read: ReportedLane = {
69    lane,
70    title,
71    status,
72    agent: optionalText(value.agent),
73    worktree: optionalText(value.worktree),
74    summary: optionalText(value.summary),
75    eta: optionalText(value.eta),
76    todos: listOf(value.todos, readTodo),
77    resources: listOf(value.resources, readResource),
78  }
79
80  return read
81}
82
83/**
84 * Read the arguments of a `report` call. Returns the reason when a required field is missing.
85 */
86export const parseReport = (input: Record<string, unknown>): ReportParse => {
87  const { program, lanes = [], closed, needs, eta } = input
88
89  if (typeof program !== 'string' || !isProgramName(program)) {
90    return { isValid: false, reason: 'program must be one lower-case path segment, for example "niagara-consolidation".' }
91  }
92  if (!Array.isArray(lanes)) {
93    return { isValid: false, reason: 'lanes must be an array of the lanes that changed.' }
94  }
95
96  const read = lanes.map(readLane)
97  const badIndex = read.findIndex(lane => lane === null)
98  if (badIndex >= 0) {
99    return { isValid: false, reason: `lanes[${badIndex}] needs lane, title, and status as strings.` }
100  }
101
102  const report: Report = {
103    program,
104    lanes: read.filter(lane => lane !== null),
105    closed: Array.isArray(closed) ? closed.filter(name => typeof name === 'string') : [],
106    needs: Array.isArray(needs) ? needs.filter(need => typeof need === 'string') : undefined,
107    eta: optionalText(eta),
108  }
109
110  return { isValid: true, report }
111}
112
hooks/progress-bar.ts 11 lines
1/**
2 * Draw `done` of `total` as an ASCII bar, for example `#####.....` for 5 of 10.
3 *
4 * @param width the bar length in cells, at least 1
5 */
6export const progressBar = (done: number, total: number, width: number) => {
7  const filled = total === 0 ? 0 : Math.round((Math.min(done, total) / total) * width)
8
9  return `${'#'.repeat(filled)}${'.'.repeat(width - filled)}`
10}
11
types/index.d.ts 122 lines
1/**
2 * One step a lane's agent plans, with its own rough estimate.
3 */
4export type Todo = {
5  text: string
6  state: 'pending' | 'active' | 'done'
7  eta?: string
8}
9
10/**
11 * Something a lane owns, labeled by its agent or found by the mod: a dev server, a port, a PR, a log.
12 */
13export type LaneResource = {
14  label: string
15  value: string
16  isObserved?: boolean
17}
18
19/**
20 * One lane as its agent reports it, plus the time its status last changed.
21 */
22export type Lane = {
23  lane: string
24  title: string
25  status: string
26  statusSince: number
27  reportedAt: number
28  agent?: string
29  worktree?: string
30  summary?: string
31  eta?: string
32  todos: Todo[]
33  resources: LaneResource[]
34}
35
36/**
37 * One live agent of a session, with the time of its last tool call.
38 */
39export type AgentRow = {
40  id: string
41  name: string
42  status: string
43  lastSeen: number
44  isStalled: boolean
45  tokenCount: number
46}
47
48/**
49 * What the mod measures for the processes that run inside one lane's worktree.
50 */
51export type LaneUsage = {
52  rssGb: number
53  cpuPercent: number
54  processCount: number
55  ports: LaneResource[]
56}
57
58/**
59 * What one session writes for its program, at `programs/<program>/<sessionId>.json` in the plugin data folder.
60 */
61export type SessionFile = {
62  sessionId: string
63  program: string
64  updatedAt: number
65  lanes: Lane[]
66  needs: string[]
67  eta?: string
68  agents: AgentRow[]
69  usage: Record<string, LaneUsage>
70  totalRssGb: number
71}
72
73/**
74 * The merged view of every live session on one program.
75 */
76export type ProgramView = {
77  program: string
78  sessionCount: number
79  lanes: Lane[]
80  needs: string[]
81  eta?: string
82  agents: AgentRow[]
83  usage: Record<string, LaneUsage>
84  totalRssGb: number
85}
86
87/**
88 * A tool call that a hook refused.
89 */
90export type Denial = {
91  tool: string
92  reason: string
93  at: number
94}
95
96/**
97 * Per-agent bookkeeping that must survive a hot reload.
98 */
99export type Tracking = {
100  lastSeen: Record<string, number>
101  tokens: Record<string, number>
102  stallToasted: string[]
103  heavyToasted: string[]
104  lastSampleAt: number
105  lastWriteAt: number
106  lastWriteKey: string
107}
108
109declare module 'claude-code' {
110  interface PluginState {
111    foreman: {
112      program: string | null
113      own: SessionFile | null
114      view: ProgramView | null
115      programs: string[]
116      waiting: string[]
117      denials: Denial[]
118      tracking: Tracking
119    }
120  }
121}
122