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

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.
claude plugin marketplace add KodingDev/skills
claude plugin install foreman@kodingdev
/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.
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.
worktree, with its children. The session's own processes do not count.seen.Usage needs ps and lsof, so it shows on macOS and Linux only.
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.
hooks/register.tsx 661 lines1import { 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}
661hooks/elapsed.ts 14 lines1const 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}
14hooks/lane-card.tsx 120 lines1import 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}
120hooks/lane-processes.ts 174 lines1import 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}
174hooks/program-view.ts 60 lines1import 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}
60hooks/report-input.ts 112 lines1import 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}
112hooks/progress-bar.ts 11 lines1/**
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}
11types/index.d.ts 122 lines1/**
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