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…

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.
/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.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.
/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./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
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.
/orphan-server stop <pid> stops a server.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.
npm run dev, make serve) is not listed, because its parent is the wrapper and not 1.lsof and ps answer for your own processes only.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
hooks/register.ts 261 lines1import 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}
261hooks/orphans.ts 304 lines1/** 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