SLOPSHOPPER

agent-roster

A live /roster pane (the roster as text over Remote Control) listing the Claude sessions registered in the configured config dirs (`ROSTER_CONFIG_DIRS`), plus…

newpanebandcommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-roster
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ ○ 0 idle updated 0s ago [ refresh ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ All ] ⎿ Read 6 lines │ ┃ No live sessions found. ⏺ 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 │ │ › /roster │ ⎿ agent-roster: Agents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
○ 0 idle updated 0s ago [ refresh ] [ All ] No live sessions found.
README

agent-roster

/roster opens a pane of every Claude Code session running on this machine, in three sections:

  • Needs you (red, with a red ?) and Working (green): one card each, its border the status. The card leads with the session's title (your /rename, else the AI-written one), then tmux name · repo/worktree · branch, why it waits, and the last prompt you typed with its age (you 3d ago: …): teammate messages and task notifications are never shown as yours, and an old prompt says it is old.
  • Idle: one quiet line each; $ marks a session sitting in a shell. Idle for over a day folds behind show N idle for over a day.

Above the sections, one tab per repo (worktrees under their repo), All first, each with its counts beside it: red ?2 waiting on you, green ●1 working, grey ○3 idle (zeros left out); repos that need you first. Click one, Tab to it, or press its number (1 = All, then 2–9) while the pane holds the keyboard. The header counts stay global, so nothing waiting in another repo hides behind a tab.

The pane rescans every 5 s; refresh (hotkey r) rescans now and re-reads git branches, and the header says how long ago it last looked. While any session waits on you, a one-row button sits in the band above the prompt: 👥 N waiting · open roster. Click it, or focus the band and press Enter, and the pane opens exactly as /roster does. It draws after whatever other mods put in that band (census-mod's lines, context-vigil-mod's bar), yields to a survey, is left out when the band has no row to spare, is cut to the band's width, has no hotkey of its own, and is redrawn only when the count changes. The band is raised on the terminal and desktop surfaces only, so the old plain agents: N waiting status line is cleared only when every attached surface draws the band; while any attached surface lacks it (VS Code, mobile), or none is attached, the line stays. Claude Code draws that line on terminal and desktop only, so VS Code and mobile show neither; a terminal attached beside one of them shows the line and the button together.

Over Remote Control

/roster works from the Claude app, but the app (as of 2.1.287) attaches to a session as a relay only, never as a drawing surface: it is never asked to draw the pane. So when the command arrives over the bridge (origin.kind === 'bridge') its reply is the roster itself as text — the top 15 sessions, each with its last prompt — instead of opening the pane.

Opening a session

Each tmux session's row has an open button (from the phone, /roster open <tmux-name|pid>). Where it opens depends on whether the session's repo is open in VS Code:

  • A VS Code window shows the repo (any window, a worktree or subfolder of it counting): that window is raised (code <its folder>), then sent vscode://pip.agent-roster-vscode/attach?socket=…&name=…&nonce=…; the helper there focuses the tab already showing the session (one whose shell is an ancestor of the session's tmux client), else opens a new tab running tmux attach (TMUX cleared).
  • No window shows it: a new Terminal.app window attached to it; no new VS Code window is opened.

The helper extension (vscode/) is how the roster knows: it starts with every window and writes ~/.cache/agent-roster/vscode-windows/<pid>.json naming the window's folders, removed when the window closes; files whose extension host pid has died are ignored. The first time the mod loads with VS Code present and no helper, it asks once — Install, Not now (asks again in the first session started 24 hours later) or Never — and remembers the answer in $.store. The session that asks holds the question for 10 minutes first, so other sessions starting at the same moment stay quiet (two starting within the same instant can still both ask); closing it without answering lets a session started after those 10 minutes ask again. Install builds the helper and installs it into Default and every profile VS Code's storage lists: a window loads only its own profile's extensions, and one without the helper is invisible to the roster (and answers the link with "cannot be installed because it was not found"). /roster setup-vscode runs the same install any time (after a new profile, or after "Never"); by hand it is sh plugins/agent-roster/plugin/vscode/build.sh install [profile…].

The link carries a one-time token the roster writes to ~/.cache/agent-roster/attach-nonce just before sending it; the helper spends the token and ignores any link without it, so a web page opening a vscode:// link can attach nothing. Kill re-checks that the pid is still a Claude process at the moment it acts, refuses the session it runs in by pid as well as id, and signals only the process when the target's tmux session also holds this one. The session you are in is never touched. tmux mirrors every client of a session, so a view already showing it keeps working; the window may resize to the latest client. The first Terminal open asks macOS once to let Claude Code control Terminal.

Killing a session

Each row but the session the pane runs in, and those still at a startup prompt, has a kill button (click it in fullscreen, or Tab to it and Enter). The first press only arms the row: confirm kill or cancel. From the phone, or anywhere a pane is not to hand, /roster kill <tmux-name|pid>; a tmux name held on two tmux servers is refused as ambiguous, with the pids to kill by. A session at a startup prompt is refused too: it has not registered, so the pid the roster holds for it is the pane's first process, which may be a shell; open it and answer the prompt, or close its pane.

A session in its own tmux session is ended with tmux kill-session, so no orphaned shell pane is left. The socket is found by matching the session's pid against each server's pane pids (the default server first, then any server under /tmp/tmux-<uid>/), never by name alone. Otherwise the session gets SIGTERM, which can leave a shell pane behind: when it shares a tmux session with the one the command or pane runs in (by pane or by name), when no tmux server shows its pid, or when it runs outside tmux. The session the command or pane runs in is always refused.

Several accounts: /roster setup

By default the roster reads one config dir. /roster setup picks the others with one question (header 👥 Accounts): it looks in $HOME for .claude* dirs that hold a sessions/ folder, counts each one's live sessions, and asks

Show sessions from these other Claude accounts in the roster too? Pick any, or type other config dirs under Other (comma-separated paths).

as a multi-select with one option per account, <tag> — <N> live (<~/path>), the busiest first and at most four (more are named in the question; type their paths under Other). Paths typed under Other are kept only if they exist and hold sessions/; each one that is not is named in the confirmation, never dropped silently. If no other account is found the question is "No other accounts found. Add a config dir?" with No, just this account or a path typed under Other. The answer is saved in this account's $.store (roster:configDirs) and a toast and log line say Roster now shows: this account + work, personal (N live sessions). Re-run /roster setup to change it; the current choices are listed in the question. The first time the roster meets a pane of another account it cannot list yet, it offers this once, and never again once you have answered or dismissed it. The automatic offer is made only in an interactive session with a terminal or desktop surface attached (a headless -p or SDK run neither asks nor uses it up), and it is skipped while ROSTER_CONFIG_DIRS is set; /roster setup still works then, and says that the variable overrides what it saves.

ROSTER_CONFIG_DIRS still outranks what setup saved (setup says so when it is set). To set it yourself, list the config dirs, separated by :, in ROSTER_CONFIG_DIRS, and set it in each account's settings.json env:

{ "env": { "ROSTER_CONFIG_DIRS": "~/.claude:~/.claude-personal" } }

~ is your home folder, repeats are read once, and a dir that does not exist yet is an empty registry. A dir that exists but cannot be read (permissions, a dead mount) is named in a warning line at the foot of the Remote Control text and under the pane's header, and the other dirs still show. Rows from a dir other than the session's own carry a tag from its name (.claude-personal becomes personal, .claude becomes claude): on a card it ends the tmux · repo · branch line, on an idle line it follows the tmux name, and in the Remote Control text it ends the row:

2 need you · 1 working · 0 idle

NEEDS YOU
• Demo cards — cc-ledger-2 · ledger · 2m · input needed · personal

On Windows, separate with ; (an entry starting C:\ or \\server switches the split to ;). The session's own dir is always read, listed or not. If one pid is in two registries (a crashed session's file outliving it), only the more recently active row is shown.

Where the data comes from

Nothing is scraped from tmux. Every live Claude process keeps a registry file, <config dir>/sessions/<pid>.json, holding its tmux session name, cwd, status (busy, idle, waiting + waitingFor, shell) and the time of its last status change. The mod reads the config dir ($CLAUDE_CONFIG_DIR, else ~/.claude, or the dirs in ROSTER_CONFIG_DIRS) every 5 s and drops entries whose pid is no longer running (the registry outlives crashed processes). A session held at a startup prompt (trusting a folder, logging in) has not registered yet, so the roster also lists the panes on every tmux server: one running Claude with no registry entry shows under Needs you as "at a startup prompt" -- unless it is one of the following, which the sweep checks first (one batched ps -ax -o pid=,ppid=,etime=,args=, then one read per candidate and other $HOME/.claude* dir), and which are listed as quiet idle rows with a note, never a kill button and never counted as needing you:

  • running in another account (<tag>) — run /roster setup to list it: the Claude process has a registry file in another $HOME/.claude*/sessions/ dir that this mod is not reading, so it is a working session of another account, not a stray. A pane whose pid is a shell above Claude is resolved to the Claude under it first, and a record only counts if its startedAt agrees with the live process's age (a crashed session's leftover file does not label whatever reused its pid). A record with no startedAt, or a process whose age ps did not give, cannot be checked, so it is not tagged: that pane stays a startup prompt. List the dir (/roster setup, or ROSTER_CONFIG_DIRS) and it becomes an ordinary row. A dir already listed is never reported this way.
  • agents view: the pane runs claude agents, which never registers.

The registry's tmux field is <session>:@<window>.%<pane>; only the session part is the name, and it is what the sweep matches against tmux list-panes. If a scan fails, the header says why in red and the last good roster stays on screen.

Title and prompt come from the session's transcript, <config dir>/projects/<cwd slug>/<sessionId>.jsonl (a find under projects/ when the session has moved since launch): its custom-title / ai-title rows and its origin.kind: human user rows, re-read only when the file's mtime changes. The branch is git branch --show-current per cwd, cached for a minute.

None of it reaches the model: the pane, the polling and the band button cost no context. The model sees only /roster's reply: one line at the terminal, the roster text when it runs over Remote Control.

Loading it

It is a mod (a plugin of function hooks), early access in Claude Code 2.1.287. For every session, interactive and remote alike, name the folder in the env block of settings.json:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/repos/pip-skills/plugins/agent-roster/plugin" } }

or for one session, claude --plugin-dir plugins/agent-roster/plugin.

Windows

The registry listing works on Windows: the config dir is CLAUDE_CONFIG_DIR, else <home>\.claude (home: HOME, else USERPROFILE, else HOMEDRIVE+HOMEPATH), ROSTER_CONFIG_DIRS takes ;-separated drive paths and ~\ expands with that home. A row counts as live only if PowerShell (Get-CimInstance Win32_Process) shows a process whose command line is Claude's own (claude.exe, or claude-code's entrypoint: a bare node.exe or bun.exe is not enough); if PowerShell cannot be asked, the registry rows are trusted for display. /roster kill runs taskkill /PID <pid> /F only when that command line matches AND the process's creation time agrees with the registry's startedAt; otherwise it refuses and ends nothing. tmux, ps and id do not exist there, so the stray-pane sweep and the VS Code helper and its offer are skipped without a message; /roster open is unsupported and answers that the session is outside tmux. A session that Claude Code did not register is therefore not listed on Windows.

Limits

  • Read-only: it cannot switch you into another session or tmux pane.
  • The registry and transcript formats are Claude Code internals, not a published API; a release can move them.
  • Tests live beside plugin/, in plugins/agent-roster/tests/, so they do not ship; run them with claude plugin test plugins/agent-roster (or bash tests/run-mods.sh agent-roster).
Source 4 files
hooks/register.tsx 1638 lines
1import { 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'
core/home.ts 76 lines
1// 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}
76
core/windows.ts 52 lines
1// 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
types/index.d.ts 46 lines
1export type SessionRow = {
2  pid: number
3  sessionId: string
4  /** The tmux session name, absent when it runs outside tmux. */
5  tmux?: string
6  cwd: string
7  repo: string
8  /** The worktree name when cwd is under `<repo>/.claude/worktrees/<name>`. */
9  worktree?: string
10  branch?: string
11  /** The tag of the config dir it was read from, when that is not the session's own (`personal`). */
12  account?: string
13  /** `busy`, `idle`, `waiting`, `shell`, ... as the session reports it. */
14  status: string
15  /** Why it waits (`input needed`), while `status` is `waiting`. */
16  waitingFor?: string
17  /** Why a tmux pane is listed without a registry entry of its own (`agents view`, another account). */
18  note?: string
19  kind: string
20  /** Epoch ms the registry says the process started; what a Windows kill is checked against. */
21  startedAt?: number
22  /** Epoch ms of its last status change. */
23  lastActive: number
24  /** Its `/rename` title, else the AI-written one. */
25  title?: string
26  /** The last prompt the person typed (never a teammate's or a task's), whitespace collapsed. */
27  prompt?: string
28  /** Epoch ms of that prompt. */
29  promptAt?: number
30}
31
32declare module 'claude-code' {
33  interface PluginState {
34    'agent-roster': {
35      /** `error`: why the last scan failed, while the rows are the last good ones. */
36      sessions: { rows: SessionRow[]; checkedAt: number; selfId?: string; error?: string; warnings?: string[] }
37      /** The pid whose kill button was pressed and awaits confirmation. */
38      pendingKill: number | null
39      /** Whether idle sessions quiet for over a day are unfolded. */
40      showOlder: boolean
41      /** The repo tab in view; null for All. */
42      repoTab: string | null
43    }
44  }
45}
46