Test harness only: lets claude plugin test run tests/ against plugin/. Never installed; the marketplace points at plugin/.

A personal collection of Claude Code skills for serious engineering work — from PR review to architectural auditing. Built for real workflows, shared because they might help yours.
Four plugins, each with a distinct purpose:
| Plugin | Purpose |
|---|---|
| Puritan | Architectural doctrine enforcement — plan patterns, audit code, author rules |
| Tribunal | PR review workflow — fetch, triage, validate, action, and resolve GitHub PR comments |
| email-absolution | HTML email auditing and generation — doctrines, visitation, and absolution |
| django-inquisition | Django ORM performance auditor — ~70 heuristics, tier-grouped findings, signal-aware |
You can also install via Claude Code's plugin commands. From within a Claude Code session:
/plugin marketplace add ppryde/pip-skills
/plugin install puritan@ppryde/pip-skills
/plugin install tribunal@ppryde/pip-skills
/plugin install email-absolution@ppryde/pip-skills
/plugin install django-inquisition@ppryde/pip-skills
Or from the terminal CLI:
# Install all plugins
claude plugin install puritan@ppryde/pip-skills
claude plugin install tribunal@ppryde/pip-skills
claude plugin install email-absolution@ppryde/pip-skills
claude plugin install django-inquisition@ppryde/pip-skills
After installing, the skills are available as slash commands:
/puritan:covenant — architecture planning and pattern selection
/puritan:inquisition — audit codebase against configured doctrines
/puritan:scriptorium — author new architecture doctrines
/tribunal:reckoning — triage and action GitHub PR review comments
/email-absolution:elder — email planning, Q&A, and config setup
/email-absolution:visitation — audit an email template against doctrine
/email-absolution:scribe — generate a template correct by construction
/django-inquisition:optimise-orm — audit a Django file or symbol for ORM performance issues
These skills are built around two ideas:
1. Architecture should be codified, not tribal knowledge. Architectural decisions that live only in people's heads — or in an ADR doc nobody reads — don't survive team turnover or code reviews. Puritan turns those decisions into auditable doctrine files that Claude can check your code against, commit by commit.
2. PR review is a workflow, not a scroll. Bot reviewers and human reviewers leave dozens of comments across multiple rounds. Tribunal treats this as a structured workflow: fetch everything, categorise by source and type, validate each comment against the actual current code, propose fixes, apply them with your approval, and resolve the threads.
All plugins operate in the voice of a deeply principled but self-aware Puritan inspector. Violations are heresies. Fixes are absolution. The codebase is the sanctum.
The persona is flavour, not a barrier to clarity — every verdict is technically precise and actionable. The Witchfinder is dramatic, not obscure.
settings.snippets.json at the repo root contains custom spinner verbs that replace Claude Code's default "Thinking…" messages with in-character Witchfinder flavour while the skills are running.
To use them, copy the file into your Claude Code settings directory:
cp settings.snippets.json ~/.claude/settings.snippets.json
If you already have a settings.snippets.json, merge the spinnerVerbs block into it manually.
The
mode: "replace"setting replaces all default spinner verbs with these. If you'd prefer to add them alongside the defaults, change it to"append".
Issues and PRs welcome. If you extend a doctrine or add a new one, the Scriptorium skill can help you author it to the required standard.
A personal collection of Claude Code skills for serious engineering work — from PR review to architectural auditing. Built for my own workflows, shared because they might help yours.
plugin/hooks/register.tsx 1638 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { HOME_VARS_CHECKED, configRootOf, expandHome, homeOf, joinPath } from '../core/home'
5import type { HomeEnv } from '../core/home'
6import { isClaudeCommand, killArgv, onWindowsPath, procListArgv, procsInList } from '../core/windows'
7import type { SessionRow } from '../types'
8
9const PANE = 'agent-roster'
10const TITLE = 'Agents'
11const COMMAND = 'roster'
12const POLL_MS = 5000
13const BRANCH_TTL_MS = 60_000
14const PROMPT_MAX = 160
15// Idle sessions quieter than this fold behind "show older".
16const RECENT_MS = 24 * 3600_000
17const SUMMARY_ROWS = 15
18
19// Each live Claude process writes <config dir>/sessions/<pid>.json.
20async function homeEnvOf($: EngineInterface): Promise<HomeEnv> {
21 return {
22 CLAUDE_CONFIG_DIR: await $.env.get('CLAUDE_CONFIG_DIR'),
23 HOME: await $.env.get('HOME'),
24 USERPROFILE: await $.env.get('USERPROFILE'),
25 HOMEDRIVE: await $.env.get('HOMEDRIVE'),
26 HOMEPATH: await $.env.get('HOMEPATH'),
27 }
28}
29
30/** The home dir (HOME, USERPROFILE, HOMEDRIVE+HOMEPATH); '' when none is set. */
31async function homeDirOf($: EngineInterface): Promise<string> {
32 return homeOf(await homeEnvOf($)) ?? ''
33}
34
35/** The own config dir: CLAUDE_CONFIG_DIR, else <home>/.claude. Throws, naming the variables checked, when neither can be told. */
36async function configDirOf($: EngineInterface): Promise<string> {
37 const dir = configRootOf(await homeEnvOf($))
38 if (!dir) throw new Error(`no config dir: none of ${HOME_VARS_CHECKED} is set`)
39
40 return dir
41}
42
43/** Whether the roster runs on Windows: its config dir is a drive or UNC path. Where `ps`, `tmux`, `id` and `kill` do not exist. */
44async function onWindows($: EngineInterface): Promise<boolean> {
45 return onWindowsPath(await configDirOf($).catch(() => ''))
46}
47
48export type ConfigDir = { dir: string; tag?: string }
49
50/** The tag a foreign config dir's rows carry: `.claude-personal` is `personal`, `.claude` is `claude`. */
51export function configDirTag(dir: string): string {
52 const name = dir.split(/[\\/]/).filter(Boolean).pop() ?? dir
53
54 return name.replace(/^\.claude-/, '').replace(/^\./, '')
55}
56
57// "." and ".." folded away, trailing separators dropped: two spellings of one dir compare equal.
58function resolved(path: string): string {
59 const sep = path.includes('\\') && !path.includes('/') ? '\\' : '/'
60 const parts = path.split(/[\\/]/)
61 // A root the folding must not climb above: "/", "\\\\" (UNC) or "C:\\".
62 let root = ''
63 if (parts[0] === '' && parts[1] === '' && parts.length > 2) {
64 root = sep + sep
65 parts.splice(0, 2)
66 } else if (parts[0] === '' && parts.length > 1) {
67 root = sep
68 parts.shift()
69 } else if (/^[A-Za-z]:$/.test(parts[0]!) && parts.length > 1) {
70 root = parts.shift() + sep
71 }
72 const out: string[] = []
73 for (const part of parts) {
74 if (part === '.' || part === '') continue
75 if (part !== '..') out.push(part)
76 else if (out.length > 0 && out.at(-1) !== '..') out.pop()
77 else if (!root) out.push('..')
78 }
79
80 return root + out.join(sep) || '.'
81}
82
83/**
84 * The config dirs to read, from `ROSTER_CONFIG_DIRS`. The mod cannot see the
85 * platform, so it splits on ":" unless an entry starts with a drive letter
86 * (`C:\`) or is a UNC path (`\\server\share`), then on ";". `~` (or `~/`, `~\`) is HOME. Deduped by resolved path; the session's
87 * own dir is always read, first and untagged, listed or not. Unset or blank: the own dir alone.
88 */
89export function resolveConfigDirs(raw: string | undefined, home: string, own: string): ConfigDir[] {
90 if (!raw?.trim()) return [{ dir: own }]
91 const separator = /(^|;)([A-Za-z]:[\\/]|\\\\)/.test(raw) ? ';' : ':'
92 // Windows paths ignore case: "C:\\U" and "c:\\u" are one dir.
93 const key = (dir: string) => (separator === ';' ? dir.toLowerCase() : dir)
94 const seen = new Set<string>([key(resolved(own))])
95 // Always first: a list must not hide the person's own sessions.
96 const dirs: ConfigDir[] = [{ dir: own }]
97 for (const entry of raw.split(separator)) {
98 const text = entry.trim()
99 if (!text) continue
100 const dir = resolved(expandHome(text, { HOME: home }))
101 if (seen.has(key(dir))) continue
102 seen.add(key(dir))
103 dirs.push({ dir, tag: configDirTag(dir) })
104 }
105
106 return dirs
107}
108
109const sessions = atom({ plugin: 'agent-roster', key: 'sessions' } as const, {
110 rows: [],
111 checkedAt: 0,
112})
113const pendingKill = atom({ plugin: 'agent-roster', key: 'pendingKill' } as const, null)
114const showOlder = atom({ plugin: 'agent-roster', key: 'showOlder' } as const, false)
115const repoTab = atom({ plugin: 'agent-roster', key: 'repoTab' } as const, null)
116
117// Waiting first: it needs you. Then busy, then the rest.
118const RANK: Record<string, number> = { waiting: 0, busy: 1 }
119
120// Caches only: a reload starts them empty and the next poll refills them.
121const transcriptPaths = new Map<string, string>()
122// sessionId → when to look for a missing transcript again.
123const transcriptMisses = new Map<string, number>()
124const TRANSCRIPT_RETRY_MS = 60_000
125const facts = new Map<string, { mtimeMs: number; facts: TranscriptFacts }>()
126const branches = new Map<string, { at: number; branch?: string }>()
127// A folder's repo never changes: resolved once.
128const gitRepos = new Map<string, { repo: string; worktree?: string }>()
129
130export type TranscriptFacts = { title?: string; prompt?: string; promptAt?: number }
131
132export function repoOf(cwd: string): { repo: string; worktree?: string } {
133 const parts = cwd.split('/').filter(Boolean)
134 const at = parts.lastIndexOf('worktrees')
135 if (at >= 2 && parts[at - 1] === '.claude') {
136 return { repo: parts[at - 2] ?? cwd, worktree: parts[at + 1] }
137 }
138
139 return { repo: parts[parts.length - 1] ?? cwd }
140}
141
142/** The folder name Claude Code files a project's transcripts under. */
143export function projectSlug(cwd: string): string {
144 return cwd.replace(/[^A-Za-z0-9]/g, '-')
145}
146
147export function toRow(raw: unknown): SessionRow | undefined {
148 if (typeof raw !== 'object' || raw === null) return undefined
149 const d = raw as Record<string, unknown>
150 // pid 0 or 1 would make `kill` signal a process group or init: never a session.
151 const pid = d.pid
152 if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 1 || typeof d.cwd !== 'string') return undefined
153 const tmux = typeof d.tmux === 'string' ? d.tmux.split(':')[0] : undefined
154
155 return {
156 pid,
157 sessionId: String(d.sessionId ?? ''),
158 tmux: tmux || undefined,
159 cwd: d.cwd,
160 ...repoOf(d.cwd),
161 status: String(d.status ?? 'unknown'),
162 waitingFor: typeof d.waitingFor === 'string' ? d.waitingFor : undefined,
163 kind: String(d.kind ?? 'interactive'),
164 ...(typeof d.startedAt === 'number' ? { startedAt: d.startedAt } : {}),
165 lastActive: Number(d.updatedAt ?? d.statusUpdatedAt ?? 0),
166 }
167}
168
169function clip(text: string): string {
170 const flat = text.replace(/\s+/g, ' ').trim()
171 return flat.length > PROMPT_MAX ? `${flat.slice(0, PROMPT_MAX - 1)}…` : flat
172}
173
174/** The text a person typed, or undefined for markup (a slash command's record) and non-text. */
175function typedText(content: unknown): string | undefined {
176 const text =
177 typeof content === 'string'
178 ? content
179 : Array.isArray(content)
180 ? (content.find(b => b?.type === 'text') as { text?: string } | undefined)?.text
181 : undefined
182 if (!text || text.trimStart().startsWith('<')) return undefined
183
184 return text
185}
186
187/**
188 * From a transcript's title and human-prompt rows: its title (a `/rename`
189 * wins over the AI one) and the last prompt the person typed, with its time.
190 * Teammate messages and task notifications are not `origin.kind: human`.
191 */
192export function transcriptFacts(lines: string): TranscriptFacts {
193 let custom: string | undefined
194 let ai: string | undefined
195 let prompt: string | undefined
196 let promptAt: number | undefined
197 const rows = lines.trimEnd().split('\n')
198 for (let i = rows.length - 1; i >= 0; i--) {
199 let row: Record<string, any>
200 try {
201 row = JSON.parse(rows[i] ?? '')
202 } catch {
203 continue
204 }
205 if (row.type === 'custom-title' && custom === undefined && typeof row.customTitle === 'string') {
206 custom = row.customTitle
207 } else if (row.type === 'ai-title' && ai === undefined && typeof row.aiTitle === 'string') {
208 ai = row.aiTitle
209 } else if (prompt === undefined && row.type === 'user' && row.origin?.kind === 'human') {
210 const text = typedText(row.message?.content)
211 if (text) {
212 prompt = clip(text)
213 promptAt = Date.parse(row.timestamp) || undefined
214 }
215 }
216 }
217 const title = custom ?? ai
218
219 return { title: title ? clip(title) : undefined, prompt, promptAt }
220}
221
222export function sorted(rows: SessionRow[]): SessionRow[] {
223 return [...rows].sort(
224 (a, b) => (RANK[a.status] ?? 2) - (RANK[b.status] ?? 2) || b.lastActive - a.lastActive,
225 )
226}
227
228export function ago(then: number, now: number): string {
229 const s = Math.max(0, Math.round((now - then) / 1000))
230 if (s < 60) return `${s}s`
231 if (s < 3600) return `${Math.round(s / 60)}m`
232 if (s < 86400) return `${Math.round(s / 3600)}h`
233
234 return `${Math.round(s / 86400)}d`
235}
236
237export function grouped(rows: SessionRow[], now: number) {
238 const idle = rows.filter(r => r.status !== 'waiting' && r.status !== 'busy')
239
240 return {
241 waiting: rows.filter(r => r.status === 'waiting'),
242 busy: rows.filter(r => r.status === 'busy'),
243 recent: idle.filter(r => now - r.lastActive < RECENT_MS),
244 older: idle.filter(r => now - r.lastActive >= RECENT_MS),
245 }
246}
247
248export type RepoTab = { repo: string; waiting: number; busy: number; total: number; lastActive: number }
249
250/** One tab per repo (worktrees under their repo): those needing you first, then working, then recent. */
251export function repoTabs(rows: SessionRow[]): RepoTab[] {
252 const tabs = new Map<string, RepoTab>()
253 for (const r of rows) {
254 const tab = tabs.get(r.repo) ?? { repo: r.repo, waiting: 0, busy: 0, total: 0, lastActive: 0 }
255 tab.total += 1
256 if (r.status === 'waiting') tab.waiting += 1
257 if (r.status === 'busy') tab.busy += 1
258 tab.lastActive = Math.max(tab.lastActive, r.lastActive)
259 tabs.set(r.repo, tab)
260 }
261
262 return [...tabs.values()].sort(
263 (a, b) =>
264 Number(b.waiting > 0) - Number(a.waiting > 0) ||
265 Number(b.busy > 0) - Number(a.busy > 0) ||
266 b.lastActive - a.lastActive,
267 )
268}
269
270export type TabMark = { text: string; color: string }
271
272/** A tab's counts beside its name: red ? waiting, green ● working, grey ○ idle; zeros left out. */
273export function tabMarks(tab: Pick<RepoTab, 'waiting' | 'busy' | 'total'>): TabMark[] {
274 const idle = tab.total - tab.waiting - tab.busy
275 const marks: (TabMark | false)[] = [
276 tab.waiting > 0 && { text: `?${tab.waiting}`, color: 'red' },
277 tab.busy > 0 && { text: `●${tab.busy}`, color: 'green' },
278 idle > 0 && { text: `○${idle}`, color: 'gray' },
279 ]
280
281 return marks.filter((m): m is TabMark => m !== false)
282}
283
284export function headline(rows: SessionRow[]): string {
285 const waiting = rows.filter(r => r.status === 'waiting').length
286 const busy = rows.filter(r => r.status === 'busy').length
287
288 return `${waiting} need you · ${busy} working · ${rows.length - waiting - busy} idle`
289}
290
291const label = (r: SessionRow) => r.title ?? (r.worktree ? `${r.repo}/${r.worktree}` : r.repo)
292const nameOf = (r: SessionRow) => r.tmux ?? `pid ${r.pid}`
293
294/** The roster as plain text for Remote Control: sections, one or two lines a session. */
295export function summary(rows: SessionRow[], now: number, warnings: string[] = []): string {
296 const { waiting, busy, recent, older } = grouped(rows, now)
297 const lines = [headline(rows)]
298 let budget = SUMMARY_ROWS
299 const section = (heading: string, list: SessionRow[]) => {
300 if (list.length === 0 || budget <= 0) return
301 lines.push('', heading)
302 for (const r of list.slice(0, budget)) {
303 const why = r.status === 'waiting' && r.waitingFor ? ` · ${r.waitingFor}` : r.note ? ` · ${r.note}` : ''
304 const account = r.account ? ` · ${r.account}` : ''
305 lines.push(`• ${label(r)} — ${nameOf(r)} · ${r.repo} · ${ago(r.lastActive, now)}${why}${account}`)
306 if (r.prompt) lines.push(` you${r.promptAt ? ` ${ago(r.promptAt, now)}` : ''}: ${r.prompt}`)
307 }
308 budget -= list.length
309 }
310 section('NEEDS YOU', waiting)
311 section('WORKING', busy)
312 section('IDLE, LAST 24H', recent)
313 if (older.length) lines.push('', `+ ${older.length} idle for over a day`)
314 if (warnings.length) lines.push('', ...warnings.map(w => `! could not read ${w}`))
315
316 return lines.join('\n')
317}
318
319async function transcriptOf($: EngineInterface, configDir: string, row: SessionRow) {
320 if (!row.sessionId) return undefined
321 const known = transcriptPaths.get(row.sessionId)
322 if (known) return known
323 // A session before its first prompt has no transcript yet: look again later.
324 if ((transcriptMisses.get(row.sessionId) ?? 0) > Date.now()) return undefined
325 const guess = `${configDir}/projects/${projectSlug(row.cwd)}/${row.sessionId}.jsonl`
326 let path: string | undefined = (await $.fs.exists(guess)) ? guess : undefined
327 if (!path) {
328 // The session moved since launch (into a worktree, say): its transcript stays where it began.
329 const found = await $.process
330 .run(['find', `${configDir}/projects`, '-maxdepth', '2', '-name', `${row.sessionId}.jsonl`])
331 .catch(() => undefined)
332 path = found?.stdout.split('\n')[0]?.trim() || undefined
333 }
334 if (path) transcriptPaths.set(row.sessionId, path)
335 else transcriptMisses.set(row.sessionId, Date.now() + TRANSCRIPT_RETRY_MS)
336
337 return path
338}
339
340async function factsOf($: EngineInterface, configDir: string, row: SessionRow): Promise<TranscriptFacts> {
341 const path = await transcriptOf($, configDir, row)
342 if (!path) return {}
343 const stat = await $.fs.stat(path).catch(() => undefined)
344 if (!stat) return {}
345 const cached = facts.get(path)
346 if (cached && cached.mtimeMs === stat.mtimeMs) return cached.facts
347 const grep = await $.process
348 .run([
349 'grep',
350 '-h',
351 '-e',
352 '"type":"ai-title"',
353 '-e',
354 '"type":"custom-title"',
355 '-e',
356 '"origin":{"kind":"human"}',
357 path,
358 ])
359 .catch(() => undefined)
360 const found = grep ? transcriptFacts(grep.stdout) : {}
361 facts.set(path, { mtimeMs: stat.mtimeMs, facts: found })
362
363 return found
364}
365
366/** The repo a folder's git says it is (a sibling worktree under its main checkout); else by path. */
367export function repoFromGit(cwd: string, top: string, commonDir: string): { repo: string; worktree?: string } {
368 if (!commonDir.endsWith('/.git')) return repoOf(cwd)
369 const main = commonDir.slice(0, -'/.git'.length)
370 const name = (path: string) => path.split('/').filter(Boolean).pop() ?? path
371
372 return top && top !== main ? { repo: name(main), worktree: name(top) } : { repo: name(main) }
373}
374
375async function gitRepoOf($: EngineInterface, cwd: string) {
376 const known = gitRepos.get(cwd)
377 if (known) return known
378 const git = await $.process
379 .run(['git', '-C', cwd, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'])
380 .catch(() => undefined)
381 const [top = '', common = ''] = git?.exitCode === 0 ? git.stdout.trim().split('\n') : []
382 const found = repoFromGit(cwd, top, common)
383 gitRepos.set(cwd, found)
384
385 return found
386}
387
388async function branchOf($: EngineInterface, cwd: string) {
389 const cached = branches.get(cwd)
390 if (cached && Date.now() - cached.at < BRANCH_TTL_MS) return cached.branch
391 const git = await $.process
392 .run(['git', '-C', cwd, 'branch', '--show-current'])
393 .catch(() => undefined)
394 const branch = git?.exitCode === 0 ? git.stdout.trim() || undefined : undefined
395 branches.set(cwd, { at: Date.now(), branch })
396
397 return branch
398}
399
400/**
401 * The registry's entries. A missing folder (no session has run yet) is an
402 * empty registry; any other failure rejects, so the last good roster stays up
403 * with the reason instead of an empty one.
404 */
405export async function listRegistry($: EngineInterface, dir: string) {
406 try {
407 return await $.fs.list(dir)
408 } catch (err) {
409 if (!(await $.fs.exists(dir).catch(() => true))) return []
410 throw err
411 }
412}
413
414/**
415 * One pid in two registries means one of them is stale (pids are unique on a
416 * host, and a crashed session's file outlives it): the live process keeps
417 * touching its own, so the most recently active row wins, the earlier dir on a tie.
418 */
419export function newestPerPid<T extends { row: SessionRow }>(found: T[]): T[] {
420 const newest = new Map<number, T>()
421 for (const f of found) {
422 const held = newest.get(f.row.pid)
423 if (!held || f.row.lastActive > held.row.lastActive) newest.set(f.row.pid, f)
424 }
425
426 return found.filter(f => newest.get(f.row.pid) === f)
427}
428
429async function scan($: EngineInterface): Promise<{ rows: SessionRow[]; warnings: string[] }> {
430 const dirs = resolveConfigDirs(
431 // ROSTER_CONFIG_DIRS outranks what `/roster setup` saved.
432 (await $.env.get('ROSTER_CONFIG_DIRS'))?.trim() || joinDirs(await storedDirs($)) || undefined,
433 await homeDirOf($),
434 await configDirOf($),
435 )
436 const found: { row: SessionRow; configDir: string }[] = []
437 const warnings: string[] = []
438 const unlisted = new Set<string>()
439 for (const { dir: configDir, tag } of dirs) {
440 const name = tag ?? 'own dir'
441 let entries: Awaited<ReturnType<typeof listRegistry>>
442 try {
443 entries = await listRegistry($, joinPath(configDir, 'sessions'))
444 } catch (err) {
445 // One unreadable dir must not hide the others' sessions.
446 warnings.push(`${name}: ${String(err).slice(0, 120)}`)
447 unlisted.add(configDir)
448 continue
449 }
450 for (const entry of entries) {
451 if (!entry.name.endsWith('.json')) continue
452 const file = joinPath(configDir, 'sessions', entry.name)
453 let text: unknown
454 try {
455 text = await $.fs.read(file)
456 } catch (err) {
457 // Gone since the listing (a session ended) is routine; anything else is worth a warning.
458 if ((await $.fs.exists(file).catch(() => true)) && !warnings.some(w => w.startsWith(`${name}: `))) {
459 warnings.push(`${name}: ${String(err).slice(0, 120)}`)
460 }
461 continue
462 }
463 if (typeof text !== 'string') continue
464 try {
465 const row = toRow(JSON.parse(text))
466 if (row) found.push({ row: tag ? { ...row, account: tag } : row, configDir })
467 } catch {
468 // A file caught mid-write; the next poll reads it whole.
469 }
470 }
471 }
472 // The registry outlives crashed processes, and their pids get reused: keep
473 // only pids still running Claude.
474 // Every dir failing is a failed scan: the last good roster stays up with the reason.
475 if (unlisted.size === dirs.length) throw new Error(warnings.join('; '))
476 const alive = await liveClaudePids(
477 $,
478 found.map(f => f.row.pid),
479 )
480 const live = newestPerPid(found.filter(f => alive.has(f.row.pid)))
481
482 const registered = await Promise.all(
483 live.map(async ({ row, configDir }) => {
484 // One session's details failing must not take the others down.
485 try {
486 return {
487 ...row,
488 ...(await gitRepoOf($, row.cwd)),
489 branch: await branchOf($, row.cwd),
490 ...(await factsOf($, configDir, row)),
491 }
492 } catch {
493 return row
494 }
495 }),
496 )
497 const strays = await strayPanes($, {
498 pids: new Set(live.map(f => f.row.pid)),
499 tmuxNames: new Set(live.flatMap(f => (f.row.tmux ? [f.row.tmux] : []))),
500 }, dirs.map(d => d.dir))
501
502 return { rows: [...registered, ...strays], warnings }
503}
504
505// What tmux reports per pane, tab-separated, for the unregistered-session sweep.
506const PANE_FORMAT = '#{session_name}\t#{pane_pid}\t#{pane_current_command}\t#{pane_current_path}\t#{window_activity}'
507// Claude Code's binary runs under its version as its name (2.1.289), or as `claude`.
508const CLAUDE_COMMAND = /^(\d+\.\d+\.\d+|claude)$/
509
510/** From `ps -o pid=,comm=` output: the pids whose command is Claude Code (basename). */
511export function claudePidsIn(psOutput: string): Set<number> {
512 const pids = new Set<number>()
513 for (const line of psOutput.split('\n')) {
514 const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line)
515 if (!match) continue
516 const pid = Number(match[1])
517 const command = match[2]!.split('/').pop() ?? ''
518 if (pid > 1 && CLAUDE_COMMAND.test(command)) pids.add(pid)
519 }
520
521 return pids
522}
523
524/**
525 * The pids that are Claude Code, asked of the OS (PowerShell): the command line must be Claude's entrypoint (a bare
526 * node.exe or bun.exe is any Node app) and, when `requireStart`, the registry's startedAt must agree with the process's
527 * creation time (a reused pid's does not). null: the OS could not be asked.
528 */
529async function verifiedOnWindows($: EngineInterface, items: readonly { pid: number; startedAt?: number }[], requireStart: boolean): Promise<Set<number> | null> {
530 const argv = procListArgv(items.map(i => i.pid))
531 if (!argv) return new Set()
532 const out = await $.process.run(argv, { timeoutMs: 15_000 }).catch(() => undefined)
533 if (out?.exitCode !== 0) return null
534 const procs = procsInList(out.stdout)
535 const now = Date.now()
536 const ok = new Set<number>()
537 for (const { pid, startedAt } of items) {
538 const proc = procs.get(pid)
539 if (!proc || !isClaudeCommand(proc.command)) continue
540 if (requireStart && !startMatches(startedAt, now - proc.createdMs, now)) continue
541 ok.add(pid)
542 }
543
544 return ok
545}
546
547async function liveClaudePids($: EngineInterface, pids: number[]): Promise<Set<number>> {
548 if (pids.length === 0) return new Set()
549 if (await onWindows($)) return (await verifiedOnWindows($, pids.map(pid => ({ pid })), false)) ?? new Set(pids)
550 const ps = await $.process.run(['ps', '-o', 'pid=,comm=', '-p', pids.join(',')])
551
552 return claudePidsIn(ps.stdout)
553}
554
555const STARTUP_PROMPT = 'at a startup prompt (not registered yet)'
556
557/**
558 * A row from the tmux sweep, not the registry. Its pid is the pane's first
559 * process, which may be a shell above Claude, so it is never offered a kill.
560 */
561export function isStray(r: SessionRow): boolean {
562 return r.sessionId === '' && (r.waitingFor === STARTUP_PROMPT || r.note !== undefined)
563}
564
565const AGENTS_VIEW = 'agents view'
566const inAnotherAccount = (tag: string) => `running in another account (${tag}) — run /roster setup to list it`
567
568/** `claude agents`, the agents view, by its args. */
569export const isAgentsViewArgs = (args: string): boolean => /^\S+\s+agents(\s|$)/.test(args)
570
571/** From `ps -o pid=,args=` output: the pids running Claude's agents view (`claude agents`). */
572export function agentsViewPidsIn(psOutput: string): Set<number> {
573 const pids = new Set<number>()
574 for (const line of psOutput.split('\n')) {
575 const match = /^\s*(\d+)\s+(.*)$/.exec(line)
576 if (match && isAgentsViewArgs(match[2] ?? '')) pids.add(Number(match[1]))
577 }
578
579 return pids
580}
581
582export type Proc = { ppid: number; etimeMs: number | null; args: string }
583
584/** `ps` elapsed time, `[[dd-]hh:]mm:ss`, in ms; null when it is not that. */
585export function parseEtime(etime: string): number | null {
586 const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(etime.trim())
587 if (!m) return null
588 const [, d, h, mm, ss] = m
589
590 return ((Number(d ?? 0) * 24 + Number(h ?? 0)) * 3600 + Number(mm) * 60 + Number(ss)) * 1000
591}
592
593/** From `ps -ax -o pid=,ppid=,etime=,args=`: every process by pid. */
594export function procsIn(psOutput: string): Map<number, Proc> {
595 const procs = new Map<number, Proc>()
596 for (const line of psOutput.split('\n')) {
597 const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line)
598 if (m) procs.set(Number(m[1]), { ppid: Number(m[2]), etimeMs: parseEtime(m[3] ?? ''), args: m[4] ?? '' })
599 }
600
601 return procs
602}
603
604const isClaudeProc = (p: Proc): boolean => CLAUDE_COMMAND.test((p.args.split(/\s+/)[0] ?? '').split('/').pop() ?? '')
605
606/**
607 * The Claude process for a tmux pane pid. A pane's first process is often a shell above Claude, whose pid no
608 * registry knows: look down the tree for the nearest Claude. A pane that is Claude itself, has none below it,
609 * or is not in `ps` resolves to its own pid.
610 */
611export function claudePidFor(procs: ReadonlyMap<number, Proc>, panePid: number): number {
612 const self = procs.get(panePid)
613 if (self && isClaudeProc(self)) return panePid
614 const children = new Map<number, number[]>()
615 for (const [pid, p] of procs) children.set(p.ppid, [...(children.get(p.ppid) ?? []), pid])
616 const queue = [...(children.get(panePid) ?? [])]
617 for (let seen = 0; queue.length > 0 && seen < 200; seen++) {
618 const pid = queue.shift() as number
619 const p = procs.get(pid)
620 if (p && isClaudeProc(p)) return pid
621 queue.push(...(children.get(pid) ?? []))
622 }
623
624 return panePid
625}
626
627const START_TOLERANCE_MS = 120_000
628
629/**
630 * Does a registry record belong to the live process? Its `startedAt` (epoch ms) should be about `now` minus the
631 * process's elapsed time; a record a crash left behind, whose pid was reused, is days off. With nothing to
632 * compare (no startedAt, or no etime) the record cannot be told from a reused pid's, so it does not match.
633 */
634export function startMatches(startedAt: unknown, etimeMs: number | null, now: number): boolean {
635 if (typeof startedAt !== 'number' || etimeMs === null) return false
636
637 return Math.abs(now - etimeMs - startedAt) <= START_TOLERANCE_MS
638}
639
640/**
641 * The other `.claude*` dirs under HOME, whose registries this session is not
642 * reading: a pane with no entry here may be registered in one of them.
643 */
644export function otherClaudeDirs(home: string, names: readonly string[], listed: readonly string[]): string[] {
645 const skip = new Set(listed.map(resolved))
646
647 return names.filter(n => n.startsWith('.claude')).map(n => joinPath(home, n)).filter(dir => !skip.has(resolved(dir)))
648}
649
650/**
651 * Panes running Claude with no registry entry: a session held at a startup
652 * prompt (trusting a folder, a login) has not registered yet, and it is
653 * exactly one that waits on the person.
654 */
655export function strayRows(
656 panes: string,
657 registered: { pids: ReadonlySet<number>; tmuxNames: ReadonlySet<string> },
658 context: { elsewhere?: ReadonlyMap<number, string>; agentsView?: ReadonlySet<number> } = {},
659): SessionRow[] {
660 const rows: SessionRow[] = []
661 for (const line of panes.split('\n')) {
662 const [tmux, pidText, command, cwd, activity] = line.split('\t')
663 const pid = Number(pidText)
664 // A registered session whose pane holds a shell above Claude shows the
665 // shell's pid here: its tmux name still says it is listed already.
666 const isListed = registered.pids.has(pid) || registered.tmuxNames.has(tmux ?? '')
667 if (!tmux || !cwd || !(pid > 1) || isListed || !CLAUDE_COMMAND.test(command ?? '')) continue
668 const account = context.elsewhere?.get(pid)
669 const note = account !== undefined ? inAnotherAccount(account) : context.agentsView?.has(pid) ? AGENTS_VIEW : undefined
670 rows.push({
671 pid,
672 sessionId: '',
673 tmux,
674 cwd,
675 ...repoOf(cwd),
676 // A note explains a pane that is not waiting on anyone; only a true stray is a startup prompt.
677 ...(note !== undefined ? { status: 'idle', note } : { status: 'waiting', waitingFor: STARTUP_PROMPT }),
678 kind: 'interactive',
679 lastActive: Number(activity) * 1000 || 0,
680 })
681 }
682
683 return rows
684}
685
686async function strayPanes(
687 $: EngineInterface,
688 registered: { pids: ReadonlySet<number>; tmuxNames: ReadonlySet<string> },
689 listedDirs: readonly string[],
690): Promise<SessionRow[]> {
691 // tmux and ps are POSIX: no sweep on Windows, and nothing to say about it.
692 if (await onWindows($)) return []
693 const sweeps: string[] = []
694 for (const socket of await tmuxSockets($)) {
695 const panes = await $.process
696 .run(['tmux', '-L', socket, 'list-panes', '-a', '-F', PANE_FORMAT])
697 .catch(() => undefined)
698 if (panes?.exitCode === 0) sweeps.push(panes.stdout)
699 }
700 // Only a pane nothing here lists can be a stray: the rest need no extra look.
701 const candidates = [...new Set(sweeps.flatMap(s => strayRows(s, registered).map(r => r.pid)))]
702 if (candidates.length === 0) return []
703 // One ps for every process: a pane's pid is often a shell, and the Claude under it is what a registry knows.
704 const ps = await $.process.run(['ps', '-ax', '-o', 'pid=,ppid=,etime=,args=']).catch(() => undefined)
705 const procs = ps?.exitCode === 0 ? procsIn(ps.stdout) : new Map<number, Proc>()
706 const claudeOf = new Map(candidates.map(pid => [pid, claudePidFor(procs, pid)] as const))
707 const context = {
708 elsewhere: await registeredElsewhere($, claudeOf, procs, listedDirs),
709 agentsView: new Set(candidates.filter(pid => isAgentsViewArgs(procs.get(claudeOf.get(pid) ?? pid)?.args ?? ''))),
710 }
711
712 return sweeps.flatMap(s => strayRows(s, registered, context))
713}
714
715/**
716 * Panes whose Claude is registered in another `.claude*` dir under HOME, by that dir's tag. The record must be
717 * the live process's own (its start time agrees with the process's), or a dead session's leftover file would
718 * label whatever process reused its pid. Keyed by the pane's pid.
719 */
720async function registeredElsewhere(
721 $: EngineInterface,
722 claudeOf: ReadonlyMap<number, number>,
723 procs: ReadonlyMap<number, Proc>,
724 listedDirs: readonly string[],
725): Promise<Map<number, string>> {
726 const found = new Map<number, string>()
727 const home = await homeDirOf($)
728 if (!home) return found
729 const names = (await $.fs.list(home).catch(() => [])).map(e => e.name)
730 const now = Date.now()
731 for (const dir of otherClaudeDirs(home, names, listedDirs)) {
732 for (const [panePid, pid] of claudeOf) {
733 if (found.has(panePid)) continue
734 const text = await $.fs.read(joinPath(dir, 'sessions', `${pid}.json`)).catch(() => undefined)
735 let record: { pid?: unknown; startedAt?: unknown } | undefined
736 try {
737 record = typeof text === 'string' ? (JSON.parse(text) as { pid?: unknown; startedAt?: unknown }) : undefined
738 } catch {
739 record = undefined
740 }
741 if (!record || typeof record !== 'object' || (record.pid !== undefined && record.pid !== pid)) continue
742 if (startMatches(record.startedAt, procs.get(pid)?.etimeMs ?? null, now)) found.set(panePid, configDirTag(dir))
743 }
744 }
745
746 return found
747}
748
749// One scan at a time: a slow scan finishing after a newer one would put older rows back.
750let scanning: Promise<void> | undefined
751
752function refresh($: EngineInterface): Promise<void> {
753 scanning ??= rescan($).finally(() => {
754 scanning = undefined
755 })
756
757 return scanning
758}
759
760async function rescan($: EngineInterface) {
761 let rows: SessionRow[]
762 let warnings: string[]
763 try {
764 const scanned = await scan($)
765 rows = sorted(scanned.rows)
766 warnings = scanned.warnings
767 } catch (err) {
768 // Keep the last good roster on screen and say why it is stale.
769 await update($, sessions, held => ({ ...held, error: String(err).slice(0, 200) }))
770 return
771 }
772 const selfId = await $.session.id()
773 await update($, sessions, () => ({ rows, checkedAt: Date.now(), selfId, warnings }))
774 await showWaiting($, rows.filter(r => r.status === 'waiting').length)
775 await maybeOfferSetup($, rows)
776}
777
778// How many sessions wait on the person, as last drawn: the band is redrawn when this changes, and only then.
779let drawnWaiting = -1
780
781/**
782 * The band above the prompt carries the waiting count as a button (see the `AbovePrompt` hook), but it is raised on the
783 * terminal and desktop surfaces only. A session can draw on several surfaces at once, so the plain status line is
784 * cleared only when EVERY attached surface draws the band; while any one lacks it (VS Code, mobile) or none is
785 * attached, the line stays. In practice `$.ui.status` is itself drawn on terminal and desktop only, so VS Code and
786 * mobile show neither: on a mixed session the line is the fallback the engine can offer, and a terminal that is
787 * attached beside one of them shows the line and the button together.
788 */
789async function showWaiting($: EngineInterface, waiting: number) {
790 const surfaces = await $.session.surfaces().catch(() => [])
791 const everyBand = surfaces.length > 0 && surfaces.every(s => s === 'terminal' || s === 'desktop')
792 $.ui.status(!everyBand && waiting ? `agents: ${waiting} waiting` : undefined)
793 if (waiting !== drawnWaiting) {
794 drawnWaiting = waiting
795 $.ui.invalidate('ui.render')
796 }
797}
798
799/** Opens the roster pane: what `/roster` and the band's button both do. */
800async function openPane($: EngineInterface) {
801 await $.ui.open({ id: PANE, title: TITLE, focus: true })
802}
803
804/** Does a tree drawn beneath this hook put anything in the band? An empty Box (a mod with nothing to say) does not. */
805function drawsRows(tree: unknown): boolean {
806 if (tree === null || tree === undefined || tree === false || tree === '') return false
807 if (typeof tree !== 'object') return true
808 const kids = (tree as { children?: unknown }).children
809 if (Array.isArray(kids)) return kids.some(drawsRows)
810
811 return (tree as { type?: unknown }).type !== 'Box'
812}
813
814const WAITING_ROW = (n: number) => `👥 ${n} waiting · open roster`
815const fitLabel = (text: string, columns: number) => (text.length <= columns ? text : `${text.slice(0, Math.max(0, columns - 1))}…`)
816
817/** A refresh the person asked for: branches re-read now, not when their minute is up. */
818async function refreshNow($: EngineInterface) {
819 // A scan already running would put the branches it read back after a clear.
820 await scanning?.catch(() => undefined)
821 branches.clear()
822 await rescanAfterCurrent($)
823}
824
825/**
826 * A fresh scan after any one already running: that one began before a kill or
827 * a branch switch, so joining it would show the old state for another poll.
828 */
829async function rescanAfterCurrent($: EngineInterface) {
830 await scanning?.catch(() => undefined)
831 await refresh($).catch(() => undefined)
832}
833
834/** The sessions a `/roster kill` argument names: a tmux name or a pid. */
835export function matchTarget(rows: SessionRow[], target: string): SessionRow[] {
836 return rows.filter(r => r.tmux === target || String(r.pid) === target)
837}
838
839/** Every tmux server socket of this user: the default one first, any other under the tmux dir after. */
840async function tmuxSockets($: EngineInterface): Promise<string[]> {
841 if (await onWindows($)) return []
842 const uid = await $.process
843 .run(['id', '-u'])
844 .then(r => r.stdout.trim())
845 .catch(() => '')
846 const listed = uid ? await $.fs.list(`/tmp/tmux-${uid}`).catch(() => []) : []
847
848 return [...new Set(['default', ...listed.map(s => s.name)])]
849}
850
851/** The pids of every pane in a tmux session on one socket; undefined when it is not there. */
852async function panePids($: EngineInterface, socket: string, tmux: string): Promise<number[] | undefined> {
853 const panes = await $.process
854 .run(['tmux', '-L', socket, 'list-panes', '-s', '-t', `=${tmux}`, '-F', '#{pane_pid}'])
855 .catch(() => undefined)
856
857 return panes?.exitCode === 0 ? panes.stdout.split('\n').filter(Boolean).map(Number) : undefined
858}
859
860/** The tmux socket whose session of this name has the session's own pid in a pane. */
861async function socketOf($: EngineInterface, r: SessionRow): Promise<string | undefined> {
862 if (!r.tmux) return undefined
863 for (const socket of await tmuxSockets($)) {
864 // A name alone is not enough: two sockets can each hold a session of that name.
865 if ((await panePids($, socket, r.tmux))?.includes(r.pid)) return socket
866 }
867
868 return undefined
869}
870
871/**
872 * Whether `kill-session` may end the target's whole tmux session. Only when
873 * this session is known and the target holds it neither by pane (a shell above
874 * Claude shows the shell's pid there, so that alone is not enough) nor by tmux
875 * name; otherwise only the target process is signalled.
876 */
877export function mayKillTmuxSession(
878 target: { tmux?: string },
879 targetPanes: readonly number[],
880 self: { pid: number; tmux?: string } | undefined,
881): boolean {
882 if (!self) return false
883 if (targetPanes.includes(self.pid)) return false
884
885 return !(self.tmux && self.tmux === target.tmux)
886}
887
888/**
889 * Ends a session: its whole tmux session when it has one (so no orphaned
890 * shell pane is left), else SIGTERM to its pid. Never the session it runs in,
891 * never a pid that stopped being Claude since the roster looked.
892 */
893async function killSession($: EngineInterface, r: SessionRow): Promise<string> {
894 const name = nameOf(r)
895 const selfId = await $.session.id()
896 const self = selfId ? (await read($, sessions)).rows.find(s => s.sessionId === selfId) : undefined
897 if ((selfId && r.sessionId === selfId) || r.pid === self?.pid) return `Refused: ${name} is this session.`
898 if (isStray(r)) {
899 return `Refused: ${name} is not a session of this registry (${r.note ?? 'at a startup prompt, not registered yet'}), so its pid may be a shell; open it, or close its pane.`
900 }
901 if (!(await liveClaudePids($, [r.pid])).has(r.pid)) {
902 return `Refused: pid ${r.pid} is no longer a Claude session; refresh and try again.`
903 }
904 if (await onWindows($)) {
905 // A force-kill on Windows must be of THIS session's process: Claude's own command line, and a creation time that
906 // agrees with the registry's startedAt. A reused pid fails one or the other; if the OS cannot be asked, refuse.
907 const verified = await verifiedOnWindows($, [{ pid: r.pid, startedAt: r.startedAt }], true)
908 if (!verified?.has(r.pid)) {
909 return `Refused: could not confirm that pid ${r.pid} is ${name}'s Claude process (its command line and start time must match the registry); nothing was ended.`
910 }
911 }
912 const socket = await socketOf($, r)
913 const panes = socket && r.tmux ? ((await panePids($, socket, r.tmux)) ?? []) : []
914 const isWholeSession = Boolean(socket && r.tmux) && mayKillTmuxSession(r, panes, self)
915 const run = isWholeSession
916 ? await $.process.run(['tmux', '-L', socket!, 'kill-session', '-t', `=${r.tmux}`])
917 : await $.process.run(killArgv(r.pid, await onWindows($)))
918 if (run.exitCode !== 0) return `Could not kill ${name}: ${run.stderr.trim() || `exit ${run.exitCode}`}`
919
920 return isWholeSession ? `Killed tmux session ${name} (socket ${socket}).` : `${(await onWindows($)) ? 'Ended' : 'Sent SIGTERM to'} ${name}.`
921}
922
923async function killFromPane($: EngineInterface, r: SessionRow) {
924 const message = await killSession($, r).catch(err => `Could not kill: ${String(err)}`)
925 await update($, pendingKill, () => null)
926 $.ui.toast(message)
927 await rescanAfterCurrent($)
928}
929
930// What may be spliced into the shell line, the AppleScript string and the
931// link below: no quotes or spaces, no leading dash, not `.` or `..`.
932const SAFE_NAME = /^(?!-)(?!\.+$)[\w.-]+$/
933const SAFE_NONCE = /^[0-9a-f-]{16,64}$/
934
935/** The shell line that attaches a terminal to one tmux session on one socket. */
936export function attachCommand(socket: string, name: string): string | undefined {
937 if (!SAFE_NAME.test(socket) || !SAFE_NAME.test(name)) return undefined
938
939 return `tmux -L ${socket} attach -t '=${name}'`
940}
941
942/**
943 * The link the VS Code helper (`vscode/`) answers with a terminal tab attached
944 * to the session. `nonce` is a one-time token the roster leaves in a file the
945 * helper reads: a link a web page opens cannot carry it, so it does nothing.
946 */
947export function vscodeUri(socket: string, name: string, nonce: string): string | undefined {
948 if (!SAFE_NAME.test(socket) || !SAFE_NAME.test(name) || !SAFE_NONCE.test(nonce)) return undefined
949
950 return `vscode://pip.agent-roster-vscode/attach?socket=${socket}&name=${name}&nonce=${nonce}`
951}
952
953// `code`, wherever Homebrew or the app put it.
954const CODE_CLIS = ['/opt/homebrew/bin/code', '/usr/local/bin/code']
955// Where the helper has each open VS Code window name its folders, and where
956// the roster leaves the one-time token a link must carry.
957const VSCODE_WINDOWS_DIR = '.cache/agent-roster/vscode-windows'
958const VSCODE_NONCE_FILE = '.cache/agent-roster/attach-nonce'
959// A window's extension host runs as a VS Code helper process.
960// (VS Code, Code - Insiders, VSCodium: "… Helper").
961const VSCODE_HOST = /(Code|Codium)[^/]* Helper/
962
963export type VscodeWindow = { pid: number; folders: string[] }
964
965/** The folder of a live window showing the target repo, matched by repo root (worktrees and subfolders alike). */
966export function windowFolderFor(
967 windows: VscodeWindow[],
968 alive: ReadonlySet<number>,
969 rootOf: ReadonlyMap<string, string>,
970 targetRoot: string,
971): string | undefined {
972 for (const w of windows) {
973 if (!alive.has(w.pid)) continue
974 const folder = w.folders.find(f => (rootOf.get(f) ?? f) === targetRoot)
975 if (folder) return folder
976 }
977
978 return undefined
979}
980
981/** The open VS Code window folder showing this session's repo, if the helper reported one. */
982async function vscodeFolderFor($: EngineInterface, r: SessionRow): Promise<string | undefined> {
983 if (await onWindows($)) return undefined // the VS Code helper and `ps` are macOS/Linux
984 const home = await homeDirOf($)
985 if (!home) return undefined // no home: nothing to look under (a join would make it `/.cache/...`)
986 const dir = joinPath(home, VSCODE_WINDOWS_DIR)
987 const windows: VscodeWindow[] = []
988 for (const entry of await $.fs.list(dir).catch(() => [])) {
989 if (!entry.name.endsWith('.json')) continue
990 try {
991 const w = JSON.parse(String(await $.fs.read(joinPath(dir, entry.name)))) as VscodeWindow
992 if (typeof w.pid === 'number' && Array.isArray(w.folders)) windows.push(w)
993 } catch {
994 // Half-written or foreign: skip it.
995 }
996 }
997 if (windows.length === 0) return undefined
998 // A window that crashed leaves its file, and its pid gets reused: only live
999 // VS Code extension hosts count.
1000 const ps = await $.process.run(['ps', '-o', 'pid=,comm=', '-p', windows.map(w => w.pid).join(',')])
1001 const alive = new Set(
1002 ps.stdout
1003 .split('\n')
1004 .map(line => /^\s*(\d+)\s+(.*)$/.exec(line))
1005 .filter((m): m is RegExpExecArray => m !== null && VSCODE_HOST.test(m[2] ?? ''))
1006 .map(m => Number(m[1])),
1007 )
1008 const rootOf = new Map<string, string>()
1009 for (const f of new Set(windows.flatMap(w => w.folders))) rootOf.set(f, await repoRoot($, f))
1010
1011 return windowFolderFor(windows, alive, rootOf, await repoRoot($, r.cwd))
1012}
1013
1014/** The main checkout's root for a cwd (a worktree's included): where its VS Code window is. */
1015async function repoRoot($: EngineInterface, cwd: string): Promise<string> {
1016 const git = await $.process
1017 .run(['git', '-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir'])
1018 .catch(() => undefined)
1019 const common = git?.exitCode === 0 ? git.stdout.trim() : ''
1020
1021 return common.endsWith('/.git') ? common.slice(0, -'/.git'.length) : cwd
1022}
1023
1024/**
1025 * A session whose repo is open in a VS Code window opens there: the window is
1026 * raised and the helper focuses the tab already showing the session, else
1027 * opens one. A repo with no window open gets a new Terminal.app window (never
1028 * a new VS Code window). Never this session's own terminal. tmux mirrors
1029 * every client of a session, so a view already showing it keeps working.
1030 */
1031async function openSession($: EngineInterface, r: SessionRow): Promise<string> {
1032 const name = nameOf(r)
1033 const selfId = await $.session.id()
1034 if (r.sessionId === selfId) return `${name} is this session.`
1035 if (!r.tmux) return `${name} runs outside tmux: there is nothing to attach to.`
1036 const socket = await socketOf($, r)
1037 if (!socket) return `Could not find ${name}'s tmux server.`
1038 const attach = attachCommand(socket, r.tmux)
1039 const nonce = crypto.randomUUID()
1040 const uri = vscodeUri(socket, r.tmux, nonce)
1041 if (!attach || !uri) return `Refused: ${name} has a name the opener will not quote.`
1042 const clients = await $.process
1043 .run(['tmux', '-L', socket, 'list-clients', '-t', `=${r.tmux}`, '-F', '#{client_tty}'])
1044 .catch(() => undefined)
1045 const ttys = (clients?.stdout ?? '').split('\n').filter(Boolean).map(t => t.replace('/dev/', ''))
1046 const also = ttys.length ? ` (also attached on ${ttys.join(', ')})` : ''
1047
1048 const folder = await vscodeFolderFor($, r)
1049 if (folder) {
1050 // Bring that window forward first (`code <its folder>` raises the window
1051 // showing it), so the link lands there rather than in whichever was last.
1052 let isRaised = false
1053 for (const cli of CODE_CLIS) {
1054 const raised = await $.process.run([cli, folder]).catch(() => undefined)
1055 if (raised?.exitCode === 0) {
1056 isRaised = true
1057 break
1058 }
1059 }
1060 // The helper honours only a link carrying the token it finds in this file.
1061 const home = await homeDirOf($)
1062 const isArmed = home
1063 ? await $.fs
1064 .write(joinPath(home, VSCODE_NONCE_FILE), nonce)
1065 .then(() => true)
1066 .catch(() => false)
1067 : false
1068 if (isRaised && isArmed) {
1069 await $.clock.sleep(800)
1070 const sent = await $.process.run(['open', uri]).catch(() => ({ exitCode: 1 }))
1071 // The helper decides there: a tab of that window already showing the
1072 // session is focused, else a new one opens.
1073 if (sent.exitCode === 0) {
1074 return ttys.length
1075 ? `Showed ${name} in VS Code (${folder}): its tab is focused if that window has one, else a new tab opens.`
1076 : `Opened ${name} in a new VS Code terminal in ${folder}.`
1077 }
1078 }
1079 }
1080
1081 const run = await $.process.run([
1082 'osascript',
1083 '-e',
1084 'tell application "Terminal"',
1085 '-e',
1086 `do script "${attach}"`,
1087 '-e',
1088 'activate',
1089 '-e',
1090 'end tell',
1091 ])
1092 if (run.exitCode !== 0) return `Could not open ${name}: ${run.stderr.trim() || `exit ${run.exitCode}`}`
1093
1094 return `Opened ${name} in a new Terminal window${also}.`
1095}
1096
1097async function openFromPane($: EngineInterface, r: SessionRow) {
1098 $.ui.toast(await openSession($, r).catch(err => `Could not open: ${String(err)}`))
1099}
1100
1101// The VS Code helper is offered once: `$.store` keeps the answer.
1102const HELPER_CHOICE = 'vscodeHelper'
1103const VSCODE_STORAGE = 'Library/Application Support/Code/User/globalStorage/storage.json'
1104const HELPER_QUESTION =
1105 'Install the agent-roster helper into all your VS Code profiles, so open can go straight to a running window?'
1106
1107/** The names of the VS Code profiles beyond Default, from VS Code's own storage. */
1108export function profileNames(storage: unknown): string[] {
1109 const profiles = (storage as { userDataProfiles?: { name?: unknown }[] } | null)?.userDataProfiles
1110 if (!Array.isArray(profiles)) return []
1111
1112 // A name starting with a dash would read as a flag to `code --profile`.
1113 return profiles
1114 .map(p => p?.name)
1115 .filter((n): n is string => typeof n === 'string' && n.length > 0 && !n.startsWith('-'))
1116}
1117
1118async function helperInstalled($: EngineInterface, home: string): Promise<boolean> {
1119 const installed = await $.fs.list(`${home}/.vscode/extensions`).catch(() => [])
1120
1121 return installed.some(e => e.name.startsWith('pip.agent-roster-vscode-'))
1122}
1123
1124/** Builds the helper and installs it into Default and every VS Code profile. */
1125async function installHelper($: EngineInterface): Promise<string> {
1126 const home = await homeDirOf($)
1127 if (!home) return 'Could not find the VS Code settings: no home dir is set (HOME, USERPROFILE, HOMEDRIVE+HOMEPATH).'
1128 const storage = await $.fs.read(joinPath(home, VSCODE_STORAGE)).catch(() => undefined)
1129 let profiles: string[] = []
1130 try {
1131 profiles = profileNames(JSON.parse(String(storage)))
1132 } catch {
1133 // No VS Code storage yet: Default alone.
1134 }
1135 // A Dock-launched host may lack Homebrew on PATH, where `code` lives.
1136 const path = `/opt/homebrew/bin:/usr/local/bin:${(await $.env.get('PATH')) ?? '/usr/bin:/bin'}`
1137 const run = await $.process
1138 .run(['sh', `${$.plugin.root}/vscode/build.sh`, 'install', ...profiles], {
1139 env: { PATH: path },
1140 timeoutMs: 180_000,
1141 })
1142 .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: String(err) }))
1143 if (run.exitCode !== 0) {
1144 const why = run.stderr.trim().split('\n').pop() || `exit ${run.exitCode}`
1145 return `Could not install the VS Code helper: ${why}`
1146 }
1147 await $.store.set(HELPER_CHOICE, 'installed')
1148 const where = ['Default', ...profiles].join(', ')
1149
1150 return `Installed the VS Code helper into ${where}. Reload open VS Code windows (Developer: Reload Window) so open can find them.`
1151}
1152
1153// How long an open helper question keeps other sessions from asking it too.
1154const ASK_HOLD_MS = 10 * 60_000
1155
1156/** Asks once, the first time the mod loads with VS Code present and no helper. */
1157async function offerHelper($: EngineInterface) {
1158 // 'installed' or 'never' settle it; a number is "Not now" until then.
1159 const choice = await $.store.get(HELPER_CHOICE)
1160 if (typeof choice === 'string' || (typeof choice === 'number' && choice > Date.now())) return
1161 // A -p or SDK run draws nowhere: nobody to ask.
1162 if ((await $.session.surfaces()).length === 0) return
1163 if (await onWindows($)) return // no VS Code helper offer where there is no tmux to attach to
1164 const home = await homeDirOf($)
1165 if (!home || !(await $.fs.exists(joinPath(home, '.vscode')))) return
1166 if (await helperInstalled($, home)) {
1167 await $.store.set(HELPER_CHOICE, 'installed')
1168 return
1169 }
1170 // Many sessions start at once: the first to get here claims the question for
1171 // a while, so the rest stay quiet while it is open. Dismissed, the claim just
1172 // lapses and a later session asks; "Not now" waits a day.
1173 await $.store.set(HELPER_CHOICE, Date.now() + ASK_HOLD_MS)
1174 const answer = await $.ui
1175 .ask(HELPER_QUESTION, { header: 'VS Code', options: ['Install', 'Not now', 'Never'] })
1176 .catch(() => undefined)
1177 if (answer === 'Install') {
1178 $.ui.toast(await installHelper($), { timeoutMs: 10_000 })
1179 } else if (answer === 'Not now') {
1180 await $.store.set(HELPER_CHOICE, Date.now() + 24 * 3600_000)
1181 } else if (answer === 'Never') {
1182 await $.store.set(HELPER_CHOICE, 'never')
1183 $.ui.toast('Not installing the VS Code helper; /roster setup-vscode installs it any time.')
1184 }
1185}
1186
1187const USAGE =
1188 'Usage: /roster, /roster open <tmux-name|pid>, /roster kill <tmux-name|pid>, /roster setup, or /roster setup-vscode'
1189
1190const STATUS_COLOR: Record<string, string> = { waiting: 'red', busy: 'green' }
1191
1192// --- /roster setup: which other Claude accounts to show -------------------------------------------------
1193
1194const DIRS_KEY = 'roster:configDirs'
1195const OFFERED_KEY = 'roster:setupOffered'
1196const SETUP_HEADER = '👥 Accounts'
1197const PICK_QUESTION =
1198 'Show sessions from these other Claude accounts in the roster too? Pick any, or type other config dirs under Other (comma-separated paths).'
1199const NONE_NO = 'No, just this account'
1200const NONE_OTHER = 'Type a config dir under Other'plugin/core/home.ts 76 lines1// Where "home" and the config dir are, on macOS, Linux and Windows. THE SAME FILE lives in census-mod, context-vigil-mod
2// and agent-roster (plugins share no code); tests/census/test_home_copies.py fails if the copies differ.
3//
4// Rule: the config dir is CLAUDE_CONFIG_DIR, else <home>/.claude; <home> is HOME, else USERPROFILE, else
5// HOMEDRIVE+HOMEPATH. `~`, `~/x` and `~\x` expand with the same home.
6
7export type HomeEnv = {
8 CLAUDE_CONFIG_DIR?: string
9 HOME?: string
10 USERPROFILE?: string
11 HOMEDRIVE?: string
12 HOMEPATH?: string
13}
14
15/** What was looked at, for messages: name the variables actually checked. */
16export const HOME_VARS_CHECKED = 'CLAUDE_CONFIG_DIR, HOME, USERPROFILE and HOMEDRIVE+HOMEPATH'
17
18const nonEmpty = (v: string | undefined): string | undefined => (v && v.trim() ? v : undefined)
19
20/** The home dir: HOME, else USERPROFILE, else HOMEDRIVE+HOMEPATH; null when none is set. */
21export function homeOf(env: HomeEnv): string | null {
22 const home = nonEmpty(env.HOME) ?? nonEmpty(env.USERPROFILE)
23 if (home) return home
24 const drive = nonEmpty(env.HOMEDRIVE)
25 const path = nonEmpty(env.HOMEPATH)
26 return drive && path ? `${drive}${path}` : null
27}
28
29/** The separator a base path uses: a backslash when it has one and no slash (`C:\Users\x`), else a slash. */
30export const sepOf = (p: string): '/' | '\\' => (p.includes('\\') && !p.includes('/') ? '\\' : '/')
31
32/** Whether a path is absolute on either platform: `/x`, `C:\x`, `C:/x`, `\\server\share`. */
33export const isAbsolute = (p: string): boolean => p.startsWith('/') || p.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(p)
34
35/**
36 * Trailing separators dropped (slash or backslash), the root kept: `/a/b//` -> `/a/b`, `/` -> `/`,
37 * `C:\Users\x\` -> `C:\Users\x`, `C:\` -> `C:\`.
38 */
39export function trimSeps(p: string): string {
40 if (/^[A-Za-z]:$/.test(p)) return p // `C:` is the drive-relative cwd of C:, not the root `C:\`
41 if (/^[A-Za-z]:[\\/]+$/.test(p)) return `${p.slice(0, 2)}${p.includes('/') ? '/' : '\\'}`
42 const t = p.replace(/[\\/]+$/, '')
43 return t || (/^[\\/]/.test(p) ? p[0] ?? '/' : p)
44}
45
46/** `base` + parts, joined with the separator the base uses, so `C:\Users\x` + `.claude` stays all backslashes. */
47export function joinPath(base: string, ...parts: string[]): string {
48 const sep = sepOf(base)
49 const root = trimSeps(base)
50 const tail = parts.map(p => p.replace(/^[\\/]+|[\\/]+$/g, '')).filter(Boolean).join(sep)
51 const joined = root.endsWith('/') || root.endsWith('\\') || /^[A-Za-z]:$/.test(root) ? `${root}${tail}` : `${root}${sep}${tail}`
52 return tail ? joined.replace(sep === '\\' ? /\//g : /\\/g, sep) : root
53}
54
55/** `~`, `~/x` or `~\x` with the home dir (in the home's own separator); anything else unchanged. */
56export function expandHome(p: string, env: HomeEnv): string {
57 const home = homeOf(env)
58 if (!home || !(p === '~' || p.startsWith('~/') || p.startsWith('~\\'))) return p
59 return p === '~' ? trimSeps(home) : joinPath(home, p.slice(2))
60}
61
62/** The config dir: CLAUDE_CONFIG_DIR, else <home>/.claude; null when neither can be told. */
63export function configRootOf(env: HomeEnv): string | null {
64 const set = nonEmpty(env.CLAUDE_CONFIG_DIR)
65 if (set) return trimSeps(set)
66 const home = homeOf(env)
67 return home ? joinPath(home, '.claude') : null
68}
69
70/** A path as a comparison key: one separator, no trailing one, lower-cased when it is a Windows (drive or UNC) path. */
71export function pathKey(p: string): string {
72 const flat = p.replace(/\\/g, '/')
73 const trimmed = flat.length > 1 ? flat.replace(/\/+$/, '') || '/' : flat
74 return /^[A-Za-z]:/.test(p) || p.startsWith('\\\\') ? trimmed.toLowerCase() : trimmed
75}
76plugin/core/windows.ts 52 lines1// The Windows stand-ins for the POSIX tools the roster shells out to. Pure: no `$`.
2import { isAbsolute } from './home'
3
4/** A drive or UNC path is a Windows one; `/x` is POSIX. The mod cannot see the OS, so the config dir says. */
5export const onWindowsPath = (path: string): boolean => isAbsolute(path) && !path.startsWith('/')
6
7/** Pids are integers from the registry; anything else is dropped before it can reach a command line. */
8const safePids = (pids: readonly number[]): number[] => pids.filter(p => Number.isInteger(p) && p > 4)
9
10/**
11 * The PowerShell that lists, one tab-separated line per pid, `pid <TAB> creation epoch ms <TAB> command line`. The pids
12 * are integers checked above, so nothing the registry holds is ever spelled into the script as text.
13 */
14export function procListArgv(pids: readonly number[]): string[] | null {
15 const ok = safePids(pids)
16 if (ok.length === 0) return null
17 const filter = ok.map(p => `ProcessId=${p}`).join(' OR ')
18 const script =
19 `Get-CimInstance Win32_Process -Filter '${filter}' | ForEach-Object { ` +
20 '"$($_.ProcessId)`t$(([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds())`t$($_.CommandLine)" }'
21
22 return ['powershell', '-NoProfile', '-NonInteractive', '-Command', script]
23}
24
25export type WinProc = { createdMs: number; command: string }
26
27/** The `procListArgv` output as pid -> creation time and command line. */
28export function procsInList(out: string): Map<number, WinProc> {
29 const procs = new Map<number, WinProc>()
30 for (const line of out.split('\n')) {
31 const [pid, created, ...rest] = line.replace(/\r$/, '').split('\t')
32 if (/^\d+$/.test(pid ?? '') && /^\d+$/.test(created ?? '')) procs.set(Number(pid), { createdMs: Number(created), command: rest.join('\t') })
33 }
34
35 return procs
36}
37
38/**
39 * Is this command line Claude Code: the `claude` executable, or a runtime running claude-code's entrypoint? A bare
40 * `node.exe` or `bun.exe` is not: a reused pid could be any Node app.
41 */
42export function isClaudeCommand(command: string): boolean {
43 const first = /^"([^"]*)"|^(\S+)/.exec(command.trim())
44 const head = first?.[1] ?? first?.[2] ?? ''
45 if (/(^|[\\/])claude(\.exe)?$/i.test(head)) return true
46
47 return /(^|[\\/"'\s])@anthropic-ai[\\/]claude-code[\\/]/i.test(command)
48}
49
50/** Ending a pid: `kill`, or `taskkill /PID n /F` on Windows (a console app ignores the polite form). */
51export const killArgv = (pid: number, windows: boolean): string[] => (windows ? ['taskkill', '/PID', String(pid), '/F'] : ['kill', String(pid)])
52