SLOPSHOPPER

orphan-server

Lists the servers a Bash call of the model started in this repository and left listening on a port, with their age and session, and stops one with SIGTERM…

newcommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orphan-server
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /orphan-server ⎿ orphan-server: no server the model started listens in this repository ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

orphan-server

The model starts a dev server with (cmd &), the session ends, and the server keeps holding its port. Days later you get address already in use, and nothing else tells you the server still runs. This mod lists the servers a Bash call of the model started in this repository and left listening on a port, with their age and session and a stop button for each.

What it does

  1. At session start, at the end of each main-loop turn, every 60 s in an interactive session and at /orphan-server, the mod reads the processes that listen on a TCP port (lsof -nP -iTCP -sTCP:LISTEN). The 60 s scan shows a server another session left, or takes down the row of one stopped elsewhere, while this session is idle. A 60 s scan that fails logs its reason once, until a scan passes again.
  2. It keeps only a process whose parent is 1 (it outlived the shell that started it) and whose working directory is the session's git repository or a directory under it.
  3. It reads this repository's transcripts for the Bash call that started each one: a call that ran while the process started (after the model wrote it, before its result) and whose command holds the process's arguments. Only the transcript lines of the minutes that call can sit in are read, and each process is looked up once.
  4. The servers found stand in one sidebar section, oldest first, with a stop button each. In each row the ports are yellow, the age yellow and red past one day, and another session's id faint; this session stays in the default colour:

:8787 Python -m http.server 8787 · 3h · session 450600b2 [ stop :8787 ]

While the sidebar is closed, one transcript line names them with their pids, once per set of servers, and the section is drawn at the next scan after the sidebar opens.

  1. The button runs /orphan-server stop <pid>. The mod reads the process again first; a pid that no longer belongs to the listed server (another parent, other arguments, another start time) gets no signal. Otherwise it sends SIGTERM, and SIGKILL after 5 seconds if the server still runs. A line says stopped :8787 ... with stopped green, or says the server still runs after SIGKILL, those words red.

Command

/orphan-server reads the servers now and lists them with their pids /orphan-server stop <pid> SIGTERM, then SIGKILL after 5 s, to a listed server /orphan-server on | off on by default; off reads only at /orphan-server

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install orphan-server@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Restart Claude Code.
  2. Install the sidebar mod for the section and its stop buttons. Without it the mod writes one transcript line and /orphan-server stop <pid> stops a server.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.284:

❯ ./register.ts hooks: session.start, command.run{command=orphan-server}, turn.complete ❯ ./register.ts calls: $.clock.after (via later, stop), $.clock.every, $.clock.now (via listenersIn, readProc, runCommand, show, toSidebar), $.command.register, $.env.get, $.process.run (via output, rootOf), $.session.id, $.sidebar.clear (via toSidebar), $.sidebar.set (via toSidebar, toStream), $.store.get (via readSettings), $.store.set (via setEnabled), $.ui.log (via later, show, stop, tick, toStream) ❯ ./register.ts env writes: nothing ❯ ./register.ts env reads: CLAUDE_CONFIG_DIR, HOME

Reach L2, it runs processes and sends signals.

  1. Reads: the listening TCP processes (pid, ports, parent, age, arguments, working directory), and the transcript lines of this repository's sessions for the minutes around each one's start
  2. Runs: git rev-parse --show-toplevel once per session; lsof, ps and grep at session start, at each turn's end, every 60 s in an interactive session and at /orphan-server; kill -TERM and kill -KILL for a server you stop
  3. Sends: nothing to the model and nothing to the network
  4. Persists: in $.store, the on/off setting
  5. Hostile input: a process's arguments and a transcript's commands are drawn as text and compared as text, never run; the pid of a stop is read again before a signal is sent

Limits

  • Only the transcripts of the repository root and of the session's start directory are read. A server that an old session opened in another subdirectory started is not listed.
  • A server started through a wrapper that still runs (npm run dev, make serve) is not listed, because its parent is the wrapper and not 1.
  • The match is by time and command text. Two processes started in the same call with the same arguments read as one server each, and both are listed.
  • A process with arguments shorter than 3 characters is never matched.
  • A server you started by hand outside Claude Code is never listed, because no transcript holds its Bash call.
  • lsof and ps answer for your own processes only.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.ts 261 lines
1import type { EngineInterface, Register } from 'claude-code'
2import {
3  byAge, callsOf, changedText, configDirOf, cwdsOf, isInside, isSame, listenersOf, listText, logText, matchOf, patternsOf, procsOf,
4  sectionKey, sidebarButtons, sidebarLines, SLACK, stillLine, stoppedLine, transcriptDir, type Listener, type Orphan, type Proc,
5} from './orphans.ts'
6
7const ENABLED_KEY = 'enabled'
8const CONSUMER = 'orphan-server'
9const SECTION = 'orphans'
10const USAGE = 'expects nothing (the list), stop <pid>, on or off'
11
12/** How long a server has to end after SIGTERM before it gets SIGKILL. */
13const GRACE_MS = 5000
14
15/** How often an idle session scans again, so a server another session left or stopped shows without a turn. */
16const SCAN_MS = 60_000
17
18/** What one listener was found to be, so a later scan does not read the transcripts for it again. */
19type Seen = { startedAt: number; orphan: Orphan | undefined }
20
21/**
22 * The on/off setting, the repository whose servers are read, the transcript directories of its
23 * sessions, this session's id, what each listener was found to be, the servers of the last scan by pid,
24 * the pids the sidebar shows, the pids the last transcript line named, and the work in flight, so two scans never interleave.
25 * `ticking` holds while a timed scan waits or runs, and `failed` is the failure line a timed scan logged last.
26 */
27type State = {
28  enabled: boolean
29  root: string
30  dirs: string[]
31  sid: string
32  seen: Map<string, Seen>
33  listed: Map<number, Orphan>
34  shown: string
35  logged: string
36  chain: Promise<void>
37  ticking: boolean
38  failed: string
39}
40
41function errorText(err: unknown): string {
42  return err instanceof Error ? err.message : String(err)
43}
44
45/** A command's standard output. A non-zero exit is an answer too: lsof, ps and grep exit 1 on nothing found. */
46async function output($: EngineInterface, argv: string[], cwd?: string): Promise<string> {
47  return (await $.process.run(argv, cwd === undefined ? undefined : { cwd })).stdout
48}
49
50/** The repository the session started in, or its start directory where git does not answer. */
51async function rootOf($: EngineInterface, cwd: string): Promise<string> {
52  try {
53    const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
54    if (r.exitCode === 0 && r.stdout.trim() !== '') return r.stdout.trim()
55  } catch {
56    // git is missing; the start directory is the scope.
57  }
58  return cwd
59}
60
61/** The processes that listen on TCP, whose parent is 1, and whose working directory is inside the repository. */
62async function listenersIn($: EngineInterface, state: State): Promise<Listener[]> {
63  const ports = listenersOf(await output($, ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-Fpn']))
64  if (ports.size === 0) return []
65  const procs = procsOf(await output($, ['ps', '-o', 'pid=,ppid=,etime=,args=', '-p', [...ports.keys()].join(',')]), await $.clock.now())
66  const orphaned = procs.filter(p => p.ppid === 1)
67  if (orphaned.length === 0) return []
68  const cwds = cwdsOf(await output($, ['lsof', '-a', '-p', orphaned.map(p => p.pid).join(','), '-d', 'cwd', '-Fpn']))
69  return orphaned.filter(p => isInside(cwds.get(p.pid), state.root)).map(p => ({ ...p, ports: ports.get(p.pid) ?? [] }))
70}
71
72const seenKey = (p: Proc): string => `${p.pid} ${p.args}`
73
74function seenOf(state: State, p: Proc): Seen | undefined {
75  const seen = state.seen.get(seenKey(p))
76  return seen !== undefined && Math.abs(seen.startedAt - p.startedAt) <= SLACK ? seen : undefined
77}
78
79/**
80 * The listeners a Bash call of this project started. The transcripts are read once per new listener,
81 * and only their lines of the minutes that call can sit in, because a transcript runs to hundreds of MB.
82 */
83async function orphansOf($: EngineInterface, state: State, listeners: readonly Listener[]): Promise<Orphan[]> {
84  const fresh = listeners.filter(l => seenOf(state, l) === undefined)
85  // With no transcript directory, grep would read its standard input.
86  if (fresh.length > 0 && state.dirs.length > 0) {
87    const patterns = patternsOf(fresh).flatMap(p => ['-e', p])
88    const calls = callsOf(await output($, ['grep', '-rhsF', '--include=*.jsonl', ...patterns, ...state.dirs]))
89    for (const l of fresh) {
90      const call = matchOf(l, calls)
91      state.seen.set(seenKey(l), { startedAt: l.startedAt, orphan: call === undefined ? undefined : { ...l, sessionId: call.sessionId } })
92    }
93  }
94  return listeners.flatMap(l => seenOf(state, l)?.orphan ?? [])
95}
96
97/** The standing section, or its removal; false while the sidebar is closed or missing. */
98async function toSidebar($: EngineInterface, state: State, orphans: readonly Orphan[]): Promise<boolean> {
99  try {
100    if (orphans.length === 0) {
101      await $.sidebar.clear({ consumer: CONSUMER, key: SECTION })
102      return true
103    }
104    const now = await $.clock.now()
105    return await $.sidebar.set({ consumer: CONSUMER, key: SECTION, title: 'orphan servers', lines: sidebarLines(orphans, now, state.sid), buttons: sidebarButtons(orphans), until: 'session', order: 16 })
106  } catch {
107    // The sidebar mod is not installed.
108    return false
109  }
110}
111
112/**
113 * The section, redrawn when the set of servers changed and drawn at the next scan when the sidebar was
114 * closed at this one; while it is closed, one transcript line per set of servers.
115 */
116async function show($: EngineInterface, state: State, orphans: readonly Orphan[]): Promise<void> {
117  const shown = byAge(orphans).map(o => o.pid).join(',')
118  if (shown === state.shown) return
119  if (await toSidebar($, state, orphans)) {
120    state.shown = shown
121    return
122  }
123  if (orphans.length > 0 && shown !== state.logged) $.ui.log(logText(orphans, await $.clock.now(), state.sid))
124  state.logged = shown
125}
126
127async function scan($: EngineInterface, state: State): Promise<Orphan[]> {
128  const orphans = await orphansOf($, state, await listenersIn($, state))
129  state.listed = new Map(orphans.map(o => [o.pid, o]))
130  await show($, state, orphans)
131  return orphans
132}
133
134/** Runs work after the work in flight, so two scans never interleave. The caller reads its failure. */
135function serial<T>(state: State, work: () => Promise<T>): Promise<T> {
136  const run = state.chain.then(work)
137  state.chain = run.then(() => undefined, () => undefined)
138  return run
139}
140
141/** Runs one scan off the hook that asked, so no turn waits on lsof or grep. */
142function later($: EngineInterface, state: State): void {
143  $.clock.after(0, () => {
144    serial(state, () => scan($, state)).catch(err => $.ui.log(`the servers were not read: ${errorText(err)}`))
145  })
146}
147
148/**
149 * The timed scan of an idle session. A tick that fires while the last one still waits or runs is
150 * skipped, and a failure is logged once until a scan passes again, so a broken lsof does not write a
151 * line a minute.
152 */
153async function tick($: EngineInterface, state: State): Promise<void> {
154  if (state.ticking) return
155  state.ticking = true
156  try {
157    await readSettings($, state)
158    if (state.enabled) await serial(state, () => scan($, state))
159    state.failed = ''
160  } catch (err) {
161    const text = `the servers were not read: ${errorText(err)}`
162    if (text !== state.failed) $.ui.log(text)
163    state.failed = text
164  } finally {
165    state.ticking = false
166  }
167}
168
169/** The process as it runs now, or undefined when it is gone. */
170async function readProc($: EngineInterface, pid: number): Promise<Proc | undefined> {
171  const procs = procsOf(await output($, ['ps', '-o', 'pid=,ppid=,etime=,args=', '-p', String(pid)]), await $.clock.now())
172  return procs.find(p => p.pid === pid)
173}
174
175/** One stream entry: `stopped` green for a server that ended, `still runs after SIGKILL` red for one that did not. */
176async function toStream($: EngineInterface, o: Orphan, isGone: boolean): Promise<void> {
177  const line = isGone ? stoppedLine(o) : stillLine(o)
178  try {
179    if (await $.sidebar.set({ consumer: CONSUMER, key: sectionKey(`stop-${o.pid}`), title: 'orphan server', lines: [line], until: 'stream' })) return
180  } catch {
181    // The sidebar mod is not installed.
182  }
183  $.ui.log(line.text)
184}
185
186/** After the grace time: SIGKILL for a server still running, then what became of it, then a new scan. */
187async function finish($: EngineInterface, state: State, o: Orphan): Promise<void> {
188  if (isSame(o, await readProc($, o.pid))) await output($, ['kill', '-KILL', String(o.pid)])
189  const isGone = !isSame(o, await readProc($, o.pid))
190  await toStream($, o, isGone)
191  await scan($, state)
192}
193
194/**
195 * SIGTERM to a listed server, and SIGKILL after the grace time. The process is read again first, so a
196 * pid the system gave to another process since the scan gets nothing.
197 */
198async function stop($: EngineInterface, state: State, pid: number): Promise<string> {
199  const o = state.listed.get(pid)
200  if (o === undefined) return `${pid} is not a listed server; /orphan-server lists them`
201  if (!isSame(o, await readProc($, pid))) return changedText(pid)
202  await output($, ['kill', '-TERM', String(pid)])
203  $.clock.after(GRACE_MS, () => {
204    serial(state, () => finish($, state, o)).catch(err => $.ui.log(`${pid} was not stopped: ${errorText(err)}`))
205  })
206  return `sent SIGTERM to ${pid}; SIGKILL follows in 5 s if it still runs`
207}
208
209/**
210 * Reads the on/off setting from the store, which every window shares, so a change made in another
211 * window applies here at the next hook that acts on it.
212 */
213async function readSettings($: EngineInterface, state: State): Promise<void> {
214  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
215}
216
217async function setEnabled($: EngineInterface, state: State, on: boolean): Promise<string> {
218  state.enabled = on
219  await $.store.set(ENABLED_KEY, on)
220  if (on) later($, state)
221  return on ? 'on: the servers are read at session start, at the end of each turn and every 60 s' : 'off: the servers are read only when you run /orphan-server'
222}
223
224async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
225  const [word, pid, ...rest] = args.trim().split(/\s+/)
226  if ((word === 'on' || word === 'off') && pid === undefined) return setEnabled($, state, word === 'on')
227  if (word === 'stop' && /^\d+$/.test(pid ?? '') && rest.length === 0) return stop($, state, Number(pid))
228  if (word !== '') return USAGE
229  // The person asked, so the list is measured now rather than read from the last scan.
230  return listText(await serial(state, () => scan($, state)), await $.clock.now(), state.sid)
231}
232
233export const register: Register = on => {
234  const state: State = { enabled: true, root: '', dirs: [], sid: '', seen: new Map(), listed: new Map(), shown: '', logged: '', chain: Promise.resolve(), ticking: false, failed: '' }
235
236  on('session.start', async ($, e, next) => {
237    const r = await next(e)
238    await readSettings($, state)
239    state.root = await rootOf($, e.cwd)
240    state.sid = await $.session.id()
241    const config = configDirOf(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
242    state.dirs = config === '' ? [] : [...new Set([transcriptDir(config, state.root), transcriptDir(config, e.cwd)])]
243    await $.command.register({ name: 'orphan-server', description: 'Servers the model started that still listen in this repository: list, stop <pid>, on, off (orphan-server)', argumentHint: '[stop <pid> | on | off]', immediate: true })
244    if (state.enabled) later($, state)
245    if (e.isInteractive) $.clock.every(SCAN_MS, () => void tick($, state))
246    return r
247  })
248
249  // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
250  on('command.run', { command: 'orphan-server' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
251
252  on('turn.complete', async ($, e, next) => {
253    const r = await next(e)
254    // A server this session started shows at the end of the turn that started it.
255    if (e.agentId !== undefined) return r
256    await readSettings($, state)
257    if (state.enabled) later($, state)
258    return r
259  })
260}
261
hooks/orphans.ts 304 lines
1/** The servers left listening, the Bash calls that started them, and the texts this mod writes. */
2
3/** One process as `ps -o pid=,ppid=,etime=,args=` names it, with its start time worked out from its age. */
4export type Proc = { pid: number; ppid: number; startedAt: number; args: string }
5
6/** A process that listens, with its TCP ports. */
7export type Listener = Proc & { ports: number[] }
8
9/** One Bash call of a transcript: its tool_use id, when the model wrote it, its command and its session. */
10export type Call = { id: string; at: number; command: string; sessionId: string }
11
12/** The Bash calls of the transcript lines read, and when each call's result was written. */
13export type Calls = { uses: Call[]; ends: Map<string, number> }
14
15/** A listener a Bash call of this project started, and the session that call belongs to. */
16export type Orphan = Listener & { sessionId: string }
17
18const SECOND = 1000
19const MINUTE = 60 * SECOND
20
21/** How far a start time read from `etime` (whole seconds) and a transcript time may sit apart. */
22export const SLACK = 2 * SECOND
23
24/** The longest a Bash call runs (its timeout ceiling), so a call with no result still has an end. */
25const LONGEST = 10 * MINUTE
26
27/** A command tail shorter than this matches too much text to name a server. */
28const MIN_TAIL = 3
29
30/** The longest label shown. */
31const MAX_LABEL = 80
32
33/** The TCP ports each pid listens on, from `lsof -nP -iTCP -sTCP:LISTEN -Fpn` (`p<pid>`, `f<fd>`, `n*:8787`). */
34export function listenersOf(out: string): Map<number, number[]> {
35  const ports = new Map<number, number[]>()
36  let pid = 0
37  for (const line of out.split('\n')) {
38    if (line.startsWith('p')) pid = Number(line.slice(1))
39    else if (line.startsWith('n') && pid > 0) addPort(ports, pid, /:(\d+)$/.exec(line)?.[1])
40  }
41  return ports
42}
43
44function addPort(ports: Map<number, number[]>, pid: number, port: string | undefined): void {
45  if (port === undefined) return
46  const list = ports.get(pid) ?? []
47  if (!list.includes(Number(port))) list.push(Number(port))
48  ports.set(pid, list)
49}
50
51/** The working directory of each pid, from `lsof -a -p <pids> -d cwd -Fpn`. */
52export function cwdsOf(out: string): Map<number, string> {
53  const cwds = new Map<number, string>()
54  let pid = 0
55  for (const line of out.split('\n')) {
56    if (line.startsWith('p')) pid = Number(line.slice(1))
57    else if (line.startsWith('n') && pid > 0) cwds.set(pid, line.slice(1))
58  }
59  return cwds
60}
61
62const ETIME = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/
63
64/** A `ps` elapsed time, `[[dd-]hh:]mm:ss`, in milliseconds. */
65export function etimeMs(text: string): number | undefined {
66  const m = ETIME.exec(text.trim())
67  if (m === null) return undefined
68  const [days, hours, minutes, seconds] = [m[1], m[2], m[3], m[4]].map(v => Number(v ?? 0))
69  return ((((days ?? 0) * 24 + (hours ?? 0)) * 60 + (minutes ?? 0)) * 60 + (seconds ?? 0)) * SECOND
70}
71
72const PS_ROW = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/
73
74/** The rows of `ps -o pid=,ppid=,etime=,args=`, each start time counted back from `now`. */
75export function procsOf(out: string, now: number): Proc[] {
76  const procs: Proc[] = []
77  for (const line of out.split('\n')) {
78    const m = PS_ROW.exec(line)
79    const age = m === null ? undefined : etimeMs(m[3] ?? '')
80    if (m === null || age === undefined) continue
81    procs.push({ pid: Number(m[1]), ppid: Number(m[2]), startedAt: now - age, args: (m[4] ?? '').trim() })
82  }
83  return procs
84}
85
86/** Whether a working directory is the repository root or under it. */
87export function isInside(cwd: string | undefined, root: string): boolean {
88  return cwd !== undefined && root !== '' && (cwd === root || cwd.startsWith(`${root}/`))
89}
90
91function baseName(path: string): string {
92  return path.slice(path.lastIndexOf('/') + 1)
93}
94
95/**
96 * What the model typed of a process: its arguments without argv0, whose path the shell resolved
97 * (`python3` runs as `.../Python.app/Contents/MacOS/Python`). A process with no arguments keeps its name.
98 */
99export function tailOf(args: string): string {
100  const at = args.indexOf(' ')
101  if (at < 0) return baseName(args)
102  const rest = args.slice(at + 1).trim()
103  return rest === '' ? baseName(args.slice(0, at)) : rest
104}
105
106/** The label the person reads: the program's name and its arguments, cut to `MAX_LABEL` characters. */
107export function labelOf(args: string): string {
108  const at = args.indexOf(' ')
109  const text = at < 0 ? baseName(args) : `${baseName(args.slice(0, at))} ${args.slice(at + 1).trim()}`
110  return text.length > MAX_LABEL ? `${text.slice(0, MAX_LABEL - 1)}…` : text
111}
112
113/**
114 * The transcript time prefixes of the minutes a Bash call that started the process can sit in: from
115 * the longest call before the start to one minute after it, in UTC as the transcript writes them.
116 */
117export function patternsOf(procs: readonly Proc[]): string[] {
118  const patterns = new Set<string>()
119  for (const p of procs) {
120    for (let t = p.startedAt - LONGEST - MINUTE; t <= p.startedAt + MINUTE; t += MINUTE) {
121      patterns.add(`"timestamp":"${new Date(t).toISOString().slice(0, 16)}`)
122    }
123  }
124  return [...patterns]
125}
126
127type Row = { timestamp?: unknown; sessionId?: unknown; message?: { content?: unknown } }
128type Block = { type?: unknown; id?: unknown; name?: unknown; input?: { command?: unknown }; tool_use_id?: unknown }
129
130/** The content blocks of one transcript line; a line that is not a message record has none. */
131function blocksOf(line: string): { row: Row; blocks: Block[] } | undefined {
132  if (!line.startsWith('{')) return undefined
133  let row: Row
134  try {
135    row = JSON.parse(line) as Row
136  } catch (err) {
137    // A line the engine is still writing is not a record yet.
138    if (err instanceof SyntaxError) return undefined
139    throw err
140  }
141  const content = row.message?.content
142  return Array.isArray(content) ? { row, blocks: content as Block[] } : undefined
143}
144
145/** A Bash tool_use block as a call; any other block is none. */
146function useOf(row: Row, b: Block, at: number): Call | undefined {
147  const command = b.input?.command
148  if (b.type !== 'tool_use' || b.name !== 'Bash' || typeof b.id !== 'string' || typeof command !== 'string') return undefined
149  return { id: b.id, at, command, sessionId: typeof row.sessionId === 'string' ? row.sessionId : '' }
150}
151
152function addBlock(calls: Calls, row: Row, b: Block): void {
153  const at = typeof row.timestamp === 'string' ? Date.parse(row.timestamp) : NaN
154  if (Number.isNaN(at)) return
155  if (b.type === 'tool_result' && typeof b.tool_use_id === 'string') {
156    calls.ends.set(b.tool_use_id, at)
157    return
158  }
159  const use = useOf(row, b, at)
160  if (use !== undefined) calls.uses.push(use)
161}
162
163/** The Bash calls and the result times of the transcript lines `grep` printed. */
164export function callsOf(out: string): Calls {
165  const calls: Calls = { uses: [], ends: new Map() }
166  for (const line of out.split('\n')) {
167    const parsed = blocksOf(line.trim())
168    for (const b of parsed?.blocks ?? []) addBlock(calls, parsed?.row ?? {}, b)
169  }
170  return calls
171}
172
173/** Whether a process started while the call ran: after the model wrote it, before its result was written. */
174function ranDuring(call: Call, end: number | undefined, startedAt: number): boolean {
175  return call.at - SLACK <= startedAt && startedAt <= (end ?? call.at + LONGEST) + SLACK
176}
177
178/**
179 * The Bash call that started a process: it ran when the process started, and its command holds what
180 * the model typed of the process. Of several, the one written closest to the start.
181 */
182export function matchOf(proc: Proc, calls: Calls): Call | undefined {
183  const tail = tailOf(proc.args)
184  if (tail.length < MIN_TAIL) return undefined
185  const fits = calls.uses.filter(c => c.command.includes(tail) && ranDuring(c, calls.ends.get(c.id), proc.startedAt))
186  return fits.sort((a, b) => Math.abs(proc.startedAt - a.at) - Math.abs(proc.startedAt - b.at))[0]
187}
188
189/** Whether a process read again is still the one listed: same parent, same arguments, same start. */
190export function isSame(listed: Proc, now: Proc | undefined): boolean {
191  return now !== undefined && now.ppid === 1 && now.args === listed.args && Math.abs(now.startedAt - listed.startedAt) <= SLACK
192}
193
194/** Durations as limit-watch and bg-tasks write them: `<1m`, `45m`, `2h 36m`, `3h`, `5d 11h`. */
195export function durationText(ms: number): string {
196  const minutes = Math.floor(Math.max(0, ms) / MINUTE)
197  if (minutes < 1) return '<1m'
198  if (minutes < 60) return `${minutes}m`
199  const hours = Math.floor(minutes / 60)
200  if (hours < 24) return minutes % 60 > 0 ? `${hours}h ${minutes % 60}m` : `${hours}h`
201  return `${Math.floor(hours / 24)}d ${hours % 24}h`
202}
203
204export function portsText(ports: readonly number[]): string {
205  return ports.map(p => `:${p}`).join(', ')
206}
207
208function sessionText(sessionId: string, current: string): string {
209  return sessionId === current ? 'this session' : `session ${sessionId.slice(0, 8)}`
210}
211
212/** One row: the ports, what runs, its age and the session whose Bash call started it. */
213export function rowText(o: Orphan, now: number, current: string): string {
214  return `${portsText(o.ports)} ${labelOf(o.args)} · ${durationText(now - o.startedAt)} · ${sessionText(o.sessionId, current)}`
215}
216
217/** Oldest first. */
218export function byAge(orphans: Iterable<Orphan>): Orphan[] {
219  return [...orphans].sort((a, b) => a.startedAt - b.startedAt)
220}
221
222/** How the sidebar colours a line or a part of one. */
223type Tone = 'ok' | 'warn' | 'error' | 'dim'
224export type Part = { text: string; kind?: Tone }
225/** A line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
226export type Line = { text: string; kind?: Tone; parts?: Part[] }
227
228const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
229
230/** A line made of parts, its `text` their texts joined. */
231const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
232
233/** A server older than this has its age drawn red. */
234const OLD_AGE = 24 * 60 * MINUTE
235
236/**
237 * One row in parts: the ports yellow, the age yellow and red past a day, another session's id faint;
238 * what runs and `this session` stay in the default colour.
239 */
240function rowLine(o: Orphan, now: number, current: string): Line {
241  const age = now - o.startedAt
242  return partsLine([
243    part(portsText(o.ports), 'warn'),
244    part(` ${labelOf(o.args)} · `, undefined),
245    part(durationText(age), age >= OLD_AGE ? 'error' : 'warn'),
246    part(' · ', undefined),
247    part(sessionText(o.sessionId, current), o.sessionId === current ? undefined : 'dim'),
248  ])
249}
250
251export function sidebarLines(orphans: readonly Orphan[], now: number, current: string): Line[] {
252  return byAge(orphans).map(o => rowLine(o, now, current))
253}
254
255/** One stop button per server, run as `/orphan-server stop <pid>`. */
256export function sidebarButtons(orphans: readonly Orphan[]): { label: string; command: string; args: string }[] {
257  return byAge(orphans).map(o => ({ label: `stop ${portsText(o.ports)}`, command: 'orphan-server', args: `stop ${o.pid}` }))
258}
259
260/** The one transcript line while the sidebar is closed. The engine adds the mod name. */
261export function logText(orphans: readonly Orphan[], now: number, current: string): string {
262  const rows = byAge(orphans).map(o => `${o.pid} ${rowText(o, now, current)}`)
263  return `${orphans.length} server(s) the model started still listen: ${rows.join('; ')}; /orphan-server stop <pid> stops one`
264}
265
266/** The `/orphan-server` answer. */
267export function listText(orphans: readonly Orphan[], now: number, current: string): string {
268  if (orphans.length === 0) return 'no server the model started listens in this repository'
269  return byAge(orphans).map(o => `${o.pid}  ${rowText(o, now, current)}`).join('\n')
270}
271
272/** The stream line of a server that ended: `stopped` green, what it was in the default colour. */
273export function stoppedLine(o: Orphan): Line {
274  return partsLine([part('stopped', 'ok'), part(` ${portsText(o.ports)} ${labelOf(o.args)}`, undefined)])
275}
276
277/** The stream line of a server SIGKILL did not end: `still runs after SIGKILL` red. */
278export function stillLine(o: Orphan): Line {
279  return partsLine([part(`${portsText(o.ports)} ${labelOf(o.args)} `, undefined), part('still runs after SIGKILL', 'error')])
280}
281
282export function changedText(pid: number): string {
283  return `${pid} is no longer the server that was listed, so nothing was sent to it`
284}
285
286/** A sidebar section key: the text cut to what the sidebar takes. */
287export function sectionKey(text: string): string {
288  return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64)
289}
290
291/** The directory the host keeps its data under: `CLAUDE_CONFIG_DIR` when set, else `~/.claude`. */
292export function configDirOf(configDir: string | undefined, home: string | undefined): string {
293  if (configDir !== undefined && configDir !== '') return configDir
294  return home !== undefined && home !== '' ? `${home}/.claude` : ''
295}
296
297/**
298 * The transcript directory of a start directory: under `projects/`, the directory with every character
299 * but a letter or a digit turned into `-` (measured on 2.1.280).
300 */
301export function transcriptDir(configDir: string, cwd: string): string {
302  return `${configDir}/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}`
303}
304