Agent Watch: one pane for every Claude Code session on your machine and its subagents, running, waiting for you, or idle

A Claude Code mod that shows every session on your machine and its subagents in one pane: which ones are working, which ones are waiting for your answer, and which ones are idle. Run five sessions in parallel and know at a glance which one needs you.
◆ Fix the checkout flow — webshop
waiting for you · permission Bash (1 min)
├ ● general-purpose · Run the e2e suite
│ running · Bash · npx playwright test
└ × Explore · Find the payment handlers
ended
● Write the release notes (here) — docs
running · Edit · CHANGELOG.md (12s)
○ Refactor the parser — compiler
idle · waiting for your message for 25 min
Inside Claude Code (2.1.287 or later):
/plugin marketplace add TheoGegout/claude-agent-watch
/plugin install agent-watch@claude-agent-watch
/reload-plugins
/agent-watch
claude plugin marketplace add TheoGegout/claude-agent-watch
claude plugin install agent-watch@claude-agent-watch
Or load it straight from a clone, for one session:
git clone https://github.com/TheoGegout/claude-agent-watch
claude --plugin-dir ./claude-agent-watch
Mods are an early-access Claude Code feature and their API can change between releases. If something breaks, see Troubleshooting.
◉ AGENT WATCH ● 2 running ● 1 waiting ● 3 idle ■ 0 ended
webshop ◆ │ ◆ Fix the checkout flow WAITING Open in the app ↗
▌◆ Fix the checkout flow 1m 12s │ ╭ ⚠ Permission / input required ───────────────╮
├ ● general-purpose 4m 02s │ │ permission Bash · Claude is waiting for you │
└ ✓ Explore DONE │ ╰──────────────────────────────────────────────╯
○ Explore new UI 1h 12m │ CONVERSATION
docs ● │ You 10:02 Add the coupon field to checkout
● Release notes (here) 12s │ Claude 10:03 The coupon field is in.
compiler ✓ │ SUBAGENTS · 2
✓ Refactor the parser DONE │ ● general-purpose · Run the e2e suite RUNNING
j ↓ k ↑ o Open ↗ ⟳ Refresh ◍ English ◔ Notifications On ● 3 running · 1 waiting
The sessions on the left, grouped by project, one line each, the way herdr lists its agents; the selected session's page on the right. A project's mark is its most urgent session's.
| Mark | State | Means |
|---|---|---|
✓ | done | finished, or turned idle, since you last opened it; opening it clears the mark |
● amber, WAITING | waiting for you | a permission dialog, a question (AskUserQuestion) or a plan to approve is open |
● green, RUNNING | running | the session or a subagent is working; the tool in use when known |
● grey, IDLE | idle | the turn is over, the session waits for your next message |
● red, ENDED | ended | the subagent finished (kept 10 minutes), or the session closed |
A subagent shows the tool call it is in and for how long; past 30 s it is flagged ⚠, and so is a running subagent that has written nothing for 2 minutes. Once finished, it shows its tokens: generated in all and the size of its context. The status line counts every running and waiting loop across sessions (agents ▶ 3 running · ◆ 1 waiting). When another session starts waiting for you, a notification shows in Claude Code and as a system notification with its sound; on Windows, clicking it opens that session.
A session's page shows its conversation: your last message, Claude's last words, and its last tool calls, read from the end of its transcript and kept current while the page is open.
| Command | Does |
|---|---|
/agent-watch | open the pane |
/agent-watch text | print the board as text (also what a run with no pane, such as claude -p, gets) |
Focus the pane with ctrl+x tab (or click it), then:
| Key | Does |
|---|---|
| click a session / an agent | open its page |
b | back |
j / k | next / previous session |
o | open the selected session in the app |
| click a count in the header | show only that state; again for all |
r | refresh now |
l | switch language (English / French) |
n | notifications on / off |
s | status line on / off |
esc | close |
The pane refreshes every second.
The mod only needs to be installed; the other sessions do not need it.
| It reads | For |
|---|---|
~/.claude/sessions/*.json | Claude Code's registry of live sessions: name, folder, busy / idle, and what a session waits for |
~/.claude/projects/<project>/<session>/subagents/ | each subagent's type and task (*.meta.json) and, from the end of its transcript, whether it is running or done |
~/.claude/agent-watch/*.json | what sessions that load the mod report: the tool in use, the exact waiting reason |
Each session that loads the mod writes one small file to ~/.claude/agent-watch/<session-id>.json: its state, its current tool (a name plus a short path or description) and its subagents' names. Nothing else is written, nothing leaves your machine, and it makes no network requests and calls no model.
It runs a process in these cases only:
claude://code/continue?session=… link to the system (rundll32 url.dll,FileProtocolHandler on Windows, open on macOS, xdg-open on Linux).scripts/tail.ps1, a seek to the end of the file.scripts/notify.ps1 on Windows, osascript on macOS, notify-send on Linux.claude plugin validate . prints exactly what it hooks and calls.
What is inferred, not measured. The session registry and the subagent transcripts are Claude Code internals, not a public API: a Claude Code update can change them, and the pane would then show less until the mod is updated. A subagent counts as running while it writes to its transcript, and is classified from its transcript's last entries once quiet for 30 seconds; a transcript over 4 MiB cannot be read by a mod, so it is judged by when it was last written.
agent-flow draws a live tree of the current session's subagents, from the engine's own events: precise, per tool call, terminal first. Agent Watch watches every session on the machine, including the ones that do not load it, and tells you which one needs you: the waiting ones come first, a system notification calls you, and Open ↗ takes you there. They go well together.
In /config, or under pluginConfigs["agent-watch"].options in settings.json:
| Option | Default | Meaning |
|---|---|---|
language | en | en or fr |
notifyWaiting | true | notify when another session starts waiting for you |
openOnStart | true | open the pane by itself when a session starts |
statusLine | true | show the running / waiting count in the status line |
The pane doesn't appear. Check claude --version is 2.1.287 or later, then run /reload-plugins and /agent-watch. Look in the transcript for a dim line starting agent-watch:: it names the hook that failed.
A session is missing. Only sessions on this machine, running now, are listed. If a session crashed, its registry file may linger and show it as running until Claude Code cleans it up.
A subagent shows as running long after it finished. Its transcript is over 4 MiB, so the mod judges it by its last write; it moves to ended 5 minutes after the last one.
claude plugin validate .
claude plugin test .
Loading the mod once (claude --plugin-dir .) writes the API types to .claude-plugin/types/, after which tsc -p . type-checks it.
hooks/register.tsx 959 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentCard, AgentDetail, Board, Filter, LoopState, Route, SelfStatus, SessionCard, SessionConvo, Tokens } from '../types'
5import { STRINGS } from './strings'
6import type { Strings } from './strings'
7import { agentState, clock, drawApp, drawText, liveState, sessionLink } from './view'
8import type { Actions, Kit, ViewModel } from './view'
9
10const PANE = 'agent-watch'
11const COMMAND = 'agent-watch'
12const VERSION = '0.3.1'
13// The board is read again every second: what the registry says, as it says it.
14const TICK_MS = 1000
15// Each redraw replaces the pane's buttons, and a click that spans one is lost: the clocks on the
16// board move this often, the rest only when something changed.
17const CLOCK_RUNNING_MS = 10_000
18const CLOCK_IDLE_MS = 30_000
19// A busy conversation is read again at most this often (its transcript can be large).
20const CONVO_MS = 4000
21// A session that has not written for this long is closed (or its app is).
22const STALE_MS = 20_000
23// Ended sessions stay on the board this long, then drop off.
24const KEEP_ENDED_MS = 10 * 60_000
25// A running subagent of an idle session, silent this long, was stopped rather than at work.
26const ORPHAN_MS = 3 * 60_000
27// Ids are names of files and folders: nothing else gets near a path.
28const ID = /^[A-Za-z0-9_-]{1,128}$/
29
30const self = atom({ plugin: 'agent-watch', key: 'self' } as const, {
31 title: '',
32 state: 'idle',
33 since: 0,
34 agentTools: {},
35 agentWaiting: {},
36})
37const board = atom({ plugin: 'agent-watch', key: 'board' } as const, { now: 0, sessions: [] })
38const filter = atom({ plugin: 'agent-watch', key: 'filter' } as const, 'all')
39const collapsed = atom({ plugin: 'agent-watch', key: 'collapsed' } as const, [])
40const route = atom({ plugin: 'agent-watch', key: 'route' } as const, { view: 'list' } as Route)
41const detail = atom({ plugin: 'agent-watch', key: 'detail' } as const, null as AgentDetail | null)
42const convo = atom({ plugin: 'agent-watch', key: 'convo' } as const, null as SessionConvo | null)
43const seen = atom({ plugin: 'agent-watch', key: 'seen' } as const, {} as Record<string, number>)
44
45// The words shown, set from the `language` option at load.
46let t: Strings = STRINGS.en
47let language: 'en' | 'fr' = 'en'
48let notifyWaiting = true
49let showStatusLine = true
50let openOnStart = true
51
52// Tools whose whole run is the person answering.
53function askingReason(tool: string): string | undefined {
54 if (tool === 'AskUserQuestion') return t.question
55 if (tool === 'ExitPlanMode') return t.planApproval
56 return undefined
57}
58
59const short = (text: string, max: number) => {
60 const line = text.replace(/\s+/g, ' ').trim()
61 return line.length > max ? `${line.slice(0, max - 1)}…` : line
62}
63
64const baseName = (path: string) => path.split(/[\\/]/).filter(Boolean).at(-1) ?? path
65
66/** `Tool · what it works on`; the tool's own name wins over any `tool` field of its input. */
67const describeTool = (e: { tool: string; [argument: string]: unknown }) => {
68 const pick = (key: string) => (typeof e[key] === 'string' ? (e[key] as string) : '')
69 const detail =
70 pick('description') ||
71 (pick('file_path') && baseName(pick('file_path'))) ||
72 pick('pattern') ||
73 pick('command') ||
74 pick('url')
75 return detail ? `${e.tool} · ${short(detail, 40)}` : e.tool
76}
77const describeCall = (name: string, input: unknown) =>
78 describeTool({ ...(input && typeof input === 'object' ? (input as Record<string, unknown>) : {}), tool: name })
79
80let folder = ''
81let home = ''
82let sessionId = ''
83let cwd = ''
84let wasWaiting = new Set<string>()
85let isFirstRead = true
86// The git branch of each folder, read again after a while.
87const branchCache = new Map<string, { at: number; branch?: string }>()
88// What the open conversation and agent page were last read from, and which one is wanted now:
89// a slower read of an earlier pick never overwrites the later one.
90let convoRead = { path: '', mtimeMs: 0, at: 0 }
91let convoWanted = ''
92let detailRead = { path: '', mtimeMs: 0 }
93let detailWanted = ''
94// The session the wide pane shows without one being picked; drawn by the render hook.
95let shownSession: string | undefined
96
97function setSelf($: EngineInterface, fn: (s: SelfStatus) => SelfStatus) {
98 return update($, self, fn)
99}
100
101async function setLoop($: EngineInterface, state: LoopState, extra: Partial<SelfStatus> = {}) {
102 const now = await $.clock.now()
103 await setSelf($, s => ({
104 ...s,
105 ...extra,
106 state,
107 since: s.state === state ? s.since : now,
108 }))
109}
110
111async function writeOwn($: EngineInterface) {
112 if (!folder || !ID.test(sessionId)) return
113 const me = await read($, self)
114 const now = await $.clock.now()
115 const agents: AgentCard[] = (await $.agent.list().catch(() => [])).map(a => ({
116 id: a.id,
117 label: a.name ?? a.description,
118 type: a.type,
119 status: a.status,
120 tool: me.agentTools[a.id],
121 waiting: me.agentWaiting[a.id],
122 parentId: a.parentId,
123 }))
124 const card: SessionCard = {
125 sessionId,
126 cwd,
127 title: me.title,
128 state: me.state,
129 waiting: me.waiting,
130 tool: me.tool,
131 toolSince: me.toolSince,
132 since: me.since,
133 updatedAt: now,
134 agents,
135 notify: notifyWaiting,
136 }
137 await $.fs.write(`${folder}/${sessionId}.json`, JSON.stringify(card))
138}
139
140/** After a /clear or a resume the process goes on under a new id, and no session.start says so. */
141async function followSessionId($: EngineInterface) {
142 const id = await $.session.id().catch(() => sessionId)
143 if (!id || id === sessionId || !ID.test(id)) return
144 sessionId = id
145 const now = await $.clock.now()
146 await setSelf($, () => ({ title: '', state: 'idle', since: now, agentTools: {}, agentWaiting: {} }))
147}
148
149// Claude Code's own registry of live sessions, one file per process: every
150// session is there, whether it loads this mod or not.
151type Registered = {
152 sessionId: string
153 cwd: string
154 name?: string
155 status?: string
156 waitingFor?: string
157 startedAt?: number
158 updatedAt?: number
159 statusUpdatedAt?: number
160 hostSessionId?: string
161}
162type Verdict = { state: LoopState; tool?: string; toolSince?: number; tokens?: Tokens }
163
164const metaCache = new Map<string, { agentType?: string; description?: string }>()
165const tailCache = new Map<string, { mtimeMs: number; readAt: number; verdict: Verdict }>()
166
167// A transcript that keeps changing is read again at most this often.
168const REREAD_MS = 5_000
169// A finished subagent stays on the board this long.
170const KEEP_AGENT_MS = 10 * 60_000
171// What one $.fs.read may copy.
172const READ_LIMIT = 4 * 1024 * 1024 - 64 * 1024
173
174const projectDir = (path: string) => path.replace(/[^A-Za-z0-9]/g, '-')
175const isNum = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)
176const str = (s: unknown) => (typeof s === 'string' ? s : undefined)
177const num = (n: unknown) => (isNum(n) ? n : undefined)
178
179async function readJson($: EngineInterface, path: string): Promise<unknown> {
180 try {
181 return JSON.parse(await $.fs.read(path))
182 } catch {
183 return undefined
184 }
185}
186
187/** A registry file as the mod needs it, or nothing when it does not hold one. */
188function asRegistered(raw: unknown): Registered | undefined {
189 if (!raw || typeof raw !== 'object') return undefined
190 const r = raw as Record<string, unknown>
191 const id = str(r.sessionId)
192 const where = str(r.cwd)
193 if (!id || !ID.test(id) || !where) return undefined
194 const host = str(r.hostSessionId)
195 return {
196 sessionId: id,
197 cwd: where,
198 name: str(r.name),
199 status: str(r.status),
200 waitingFor: str(r.waitingFor),
201 startedAt: num(r.startedAt),
202 updatedAt: num(r.updatedAt),
203 statusUpdatedAt: num(r.statusUpdatedAt),
204 hostSessionId: host && /^local_[A-Za-z0-9-]{1,64}$/.test(host) ? host : undefined,
205 }
206}
207
208/** A session's own report, when it is whole and of a shape this version reads. */
209function asReport(raw: unknown): SessionCard | undefined {
210 if (!raw || typeof raw !== 'object') return undefined
211 const r = raw as Partial<SessionCard>
212 if (typeof r.sessionId !== 'string' || !ID.test(r.sessionId)) return undefined
213 if (typeof r.cwd !== 'string' || !isNum(r.updatedAt) || !isNum(r.since) || !Array.isArray(r.agents)) return undefined
214 const states: LoopState[] = ['running', 'waiting', 'idle', 'ended']
215 if (!states.includes(r.state as LoopState)) return undefined
216 const agents = r.agents.filter(
217 (a): a is AgentCard => !!a && typeof a === 'object' && typeof a.id === 'string' && typeof a.status === 'string',
218 )
219 return { ...(r as SessionCard), title: typeof r.title === 'string' ? r.title : '', agents }
220}
221
222type Usage = { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }
223type Row = {
224 type?: string
225 uuid?: string
226 timestamp?: string
227 message?: { id?: string; stop_reason?: string | null; content?: unknown; usage?: Usage }
228}
229
230/**
231 * What a subagent's transcript says about it: where it stands from its last
232 * rows, the tool it is in and since when, and its tokens (generated in all
233 * when `isWhole`, the transcript read from its first line; its context's size).
234 */
235function classifyLines(lines: string[], isWhole: boolean): Verdict {
236 // One response is written as one row per content block, its output count growing row by row:
237 // its last row's count is the response's.
238 const outByResponse = new Map<string, number>()
239 let ctx: number | undefined
240 let last: Row | undefined
241 for (const line of lines) {
242 let row: Row
243 try {
244 row = JSON.parse(line)
245 } catch {
246 continue
247 }
248 if (row.type !== 'assistant' && row.type !== 'user') continue
249 last = row
250 const usage = row.type === 'assistant' ? row.message?.usage : undefined
251 if (usage) {
252 const id = row.message?.id ?? row.uuid ?? line
253 outByResponse.set(id, Math.max(outByResponse.get(id) ?? 0, usage.output_tokens ?? 0))
254 ctx = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0)
255 }
256 }
257 const out = [...outByResponse.values()].reduce((sum, n) => sum + n, 0)
258 const tokens: Tokens | undefined = ctx === undefined ? undefined : { out: isWhole ? out : undefined, ctx }
259 if (last?.type === 'assistant') {
260 const content = Array.isArray(last.message?.content) ? last.message.content : []
261 const call = content.find((c: { type?: string }) => c && c.type === 'tool_use') as
262 | { name?: string; input?: unknown }
263 | undefined
264 if (call?.name) {
265 const since = last.timestamp ? Date.parse(last.timestamp) : NaN
266 return {
267 state: 'running',
268 tool: describeCall(call.name, call.input),
269 toolSince: Number.isNaN(since) ? undefined : since,
270 tokens,
271 }
272 }
273 return { state: last.message?.stop_reason === 'end_turn' ? 'ended' : 'running', tokens }
274 }
275 return { state: 'running', tokens }
276}
277
278async function scanAgents($: EngineInterface, card: Registered, now: number, visited: Set<string>): Promise<AgentCard[]> {
279 const dir = `${home}/.claude/projects/${projectDir(card.cwd)}/${card.sessionId}/subagents`
280 const entries = await $.fs.list(dir).catch(() => [])
281 // A subagent's meta file is written once, when it is spawned.
282 const spawned = new Map(entries.filter(e => e.name.endsWith('.meta.json')).map(e => [e.name, e.mtimeMs]))
283 const agents: AgentCard[] = []
284 for (const entry of entries) {
285 if (entry.kind !== 'file' || !entry.name.startsWith('agent-') || !entry.name.endsWith('.jsonl')) continue
286 const quiet = now - entry.mtimeMs
287 if (quiet > KEEP_AGENT_MS) continue
288 const id = entry.name.slice('agent-'.length, -'.jsonl'.length)
289 if (!ID.test(id)) continue
290 const path = `${dir}/${entry.name}`
291 visited.add(path)
292
293 let meta = metaCache.get(path)
294 if (!meta) {
295 const read = (await readJson($, `${dir}/agent-${id}.meta.json`)) as { agentType?: string; description?: string } | undefined
296 // A meta file not written yet is asked again on the next pass, not remembered empty.
297 if (read && (read.agentType || read.description)) metaCache.set(path, read)
298 meta = read ?? {}
299 }
300
301 let verdict: Verdict
302 const cached = tailCache.get(path)
303 if (cached && (cached.mtimeMs === entry.mtimeMs || now - cached.readAt < REREAD_MS)) {
304 verdict = cached.verdict
305 } else {
306 const isWhole = entry.size <= READ_LIMIT
307 const lines = await tailOf($, path, entry.size, isWhole ? Number.MAX_SAFE_INTEGER : 2000).catch(() => undefined)
308 if (lines) {
309 verdict = classifyLines(lines, isWhole)
310 tailCache.set(path, { mtimeMs: entry.mtimeMs, readAt: now, verdict })
311 } else {
312 // Unreadable this time: judged by its last write, and asked again next pass.
313 verdict = cached?.verdict ?? { state: quiet < 5 * 60_000 ? 'running' : 'ended' }
314 }
315 }
316 agents.push({
317 id,
318 label: meta.description ?? id,
319 type: meta.agentType ?? 'agent',
320 status: verdict.state === 'running' ? 'running' : 'completed',
321 tool: verdict.tool,
322 toolSince: verdict.toolSince,
323 tokens: verdict.tokens,
324 lastActivity: entry.mtimeMs,
325 startedAt: spawned.get(`agent-${id}.meta.json`),
326 endedAt: verdict.state === 'running' ? undefined : entry.mtimeMs,
327 })
328 }
329 return agents.sort((a, b) => Number(a.status !== 'running') - Number(b.status !== 'running'))
330}
331
332/** The branch a folder's repository is on: its own `.git/HEAD`, a worktree's, or a parent's. */
333async function branchOf($: EngineInterface, cwd: string, now: number): Promise<string | undefined> {
334 const cached = branchCache.get(cwd)
335 if (cached && now - cached.at < 15_000) return cached.branch
336 const fromHead = (head: string) => {
337 const ref = head.trim().match(/^ref: refs\/heads\/(.+)$/)
338 return ref ? ref[1] : head.trim().slice(0, 7)
339 }
340 let branch: string | undefined
341 let dir = cwd.replace(/\\/g, '/').replace(/\/$/, '')
342 for (let depth = 0; depth < 6 && dir; depth += 1) {
343 const head = await $.fs.read(`${dir}/.git/HEAD`).catch(() => undefined)
344 if (head !== undefined) {
345 branch = fromHead(head)
346 break
347 }
348 // A worktree or a submodule: `.git` is a file naming the real git folder.
349 const pointer = await $.fs.read(`${dir}/.git`).catch(() => undefined)
350 const gitdir = pointer?.trim().match(/^gitdir:\s*(.+)$/)?.[1]?.replace(/\\/g, '/')
351 if (gitdir) {
352 const where = /^([A-Za-z]:)?\//.test(gitdir) ? gitdir : `${dir}/${gitdir}`
353 const real = await $.fs.read(`${where}/HEAD`).catch(() => undefined)
354 if (real !== undefined) branch = fromHead(real)
355 break
356 }
357 const cut = dir.lastIndexOf('/')
358 if (cut <= 0) break
359 dir = dir.slice(0, cut)
360 }
361 branchCache.set(cwd, { at: now, branch })
362 return branch
363}
364
365async function readAll($: EngineInterface) {
366 if (!folder) return
367 const now = await $.clock.now()
368 const visited = new Set<string>()
369
370 // What sessions running this mod wrote: the finer detail (tool, waiting reason).
371 const reports = new Map<string, SessionCard>()
372 for (const entry of await $.fs.list(folder).catch(() => [])) {
373 if (entry.kind !== 'file' || !entry.name.endsWith('.json') || entry.name.startsWith('_')) continue
374 if (now - entry.mtimeMs > KEEP_ENDED_MS) continue
375 const card = asReport(await readJson($, `${folder}/${entry.name}`))
376 if (card) reports.set(card.sessionId, card)
377 }
378 const isLiveReport = (r: SessionCard) => r.state !== 'ended' && now - r.updatedAt < STALE_MS
379 // The sessions that send system notifications: this one and every live one with them on.
380 const senders = [
381 ...(notifyWaiting ? [sessionId] : []),
382 ...[...reports.values()].filter(r => r.sessionId !== sessionId && isLiveReport(r) && r.notify !== false).map(r => r.sessionId),
383 ].sort()
384
385 // The registry, one entry a session: a resumed one can hold two files, the newest wins.
386 const registered = new Map<string, Registered>()
387 const registry = `${home}/.claude/sessions`
388 for (const entry of await $.fs.list(registry).catch(() => [])) {
389 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
390 const reg = asRegistered(await readJson($, `${registry}/${entry.name}`))
391 if (!reg) continue
392 const other = registered.get(reg.sessionId)
393 if (!other || (reg.updatedAt ?? 0) > (other.updatedAt ?? 0)) registered.set(reg.sessionId, reg)
394 }
395
396 const sessions: SessionCard[] = []
397 for (const reg of registered.values()) {
398 try {
399 const report = reports.get(reg.sessionId)
400 reports.delete(reg.sessionId)
401 const isFresh = report !== undefined && isLiveReport(report)
402
403 // The registry is the authority on whether a session waits; a report only says on what.
404 const state: LoopState = reg.waitingFor ? 'waiting' : reg.status === 'busy' ? 'running' : 'idle'
405 const scanned = await scanAgents($, reg, now, visited)
406 const known = isFresh ? report.agents : []
407 const merged = [
408 // What the session lists of its own: those still at work, and those its transcripts still show.
409 ...known
410 .filter(k => {
411 const st = agentState(k.status, k.waiting)
412 return st === 'running' || st === 'waiting' || scanned.some(a => a.id === k.id)
413 })
414 .map(k => {
415 const found = scanned.find(a => a.id === k.id)
416 return found
417 ? {
418 ...k,
419 startedAt: found.startedAt,
420 endedAt: found.endedAt,
421 lastActivity: found.lastActivity,
422 tokens: found.tokens,
423 toolSince: found.toolSince,
424 tool: k.tool ?? found.tool,
425 }
426 : k
427 }),
428 ...scanned.filter(a => !known.some(k => k.id === a.id)),
429 ]
430 // A subagent left "running" in a session gone idle, silent for minutes, was stopped.
431 const agents = merged.map(a =>
432 state === 'idle' && a.status === 'running' && !a.waiting && a.lastActivity !== undefined && now - a.lastActivity > ORPHAN_MS
433 ? { ...a, status: 'completed', tool: undefined, endedAt: a.lastActivity }
434 : a,
435 )
436 sessions.push({
437 sessionId: reg.sessionId,
438 hostId: reg.hostSessionId,
439 branch: await branchOf($, reg.cwd, now),
440 startedAt: reg.startedAt,
441 cwd: reg.cwd,
442 title: reg.name || report?.title || '',
443 state,
444 waiting: state === 'waiting' ? (isFresh && report.state === 'waiting' && report.waiting) || reg.waitingFor || t.waitingFallback : undefined,
445 tool: state === 'running' && isFresh ? report.tool : undefined,
446 toolSince: state === 'running' && isFresh ? report.toolSince : undefined,
447 since: reg.statusUpdatedAt ?? reg.updatedAt ?? reg.startedAt ?? now,
448 updatedAt: reg.updatedAt ?? now,
449 agents,
450 })
451 } catch (err) {
452 $.ui.log(`agent-watch: skipped session ${reg.sessionId}: ${err}`, { to: 'debug' })
453 }
454 }
455
456 // A session that reported but left the registry has ended.
457 for (const card of reports.values()) {
458 sessions.push(isLiveReport(card) ? card : { ...card, state: 'ended', waiting: undefined, tool: undefined, agents: [] })
459 }
460
461 // Caches keep what this pass met, and nothing else.
462 for (const key of [...metaCache.keys()]) if (!visited.has(key)) metaCache.delete(key)
463 for (const key of [...tailCache.keys()]) if (!visited.has(key)) tailCache.delete(key)
464 for (const key of [...branchCache.keys()]) if (!sessions.some(c => c.cwd === key)) branchCache.delete(key)
465
466 const rank: Record<LoopState, number> = { waiting: 0, running: 1, idle: 2, ended: 3 }
467 sessions.sort((a, b) => rank[liveState(a)] - rank[liveState(b)] || b.since - a.since)
468 const before = await read($, board)
469 const isRunning = sessions.some(c => liveState(c) === 'running' || liveState(c) === 'waiting')
470 const isChanged = JSON.stringify(before.sessions) !== JSON.stringify(sessions)
471 const isClockDue = now - before.now >= (isRunning ? CLOCK_RUNNING_MS : CLOCK_IDLE_MS)
472 if (isChanged || isClockDue) await update($, board, () => ({ now, sessions }))
473 await update($, seen, marks => {
474 const missing = sessions.filter(c => marks[c.sessionId] === undefined)
475 const gone = Object.keys(marks).filter(id => !sessions.some(c => c.sessionId === id))
476 if (missing.length === 0 && gone.length === 0) return marks
477 const kept = Object.fromEntries(Object.entries(marks).filter(([id]) => !gone.includes(id)))
478 return { ...kept, ...Object.fromEntries(missing.map(c => [c.sessionId, now])) }
479 })
480
481 // Status line and notifications when a session starts waiting on the person.
482 let running = 0
483 let waiting = 0
484 const waitingNow = new Set<string>()
485 for (const c of sessions) {
486 const loops = [c.state, ...c.agents.map(a => agentState(a.status, a.waiting))]
487 running += loops.filter(s => s === 'running').length
488 const waits = loops.filter(s => s === 'waiting').length
489 waiting += waits
490 if (waits > 0) waitingNow.add(c.sessionId)
491 }
492 // For a session that waits, the first of the other senders by id sends it (itself when it is
493 // the only one): nobody is left out, nobody sends it twice.
494 const senderFor = (id: string) => senders.find(s => s !== id) ?? (senders.includes(id) ? id : undefined)
495 // The sessions already waiting when this process first looked are not news.
496 if (!isFirstRead) {
497 for (const id of waitingNow) {
498 if (wasWaiting.has(id)) continue
499 const c = sessions.find(one => one.sessionId === id)
500 if (!c) continue
501 const who = `${baseName(c.cwd)} — ${short(c.title || t.newSession, 40)}`
502 if (notifyWaiting && id !== sessionId) $.ui.toast(t.isWaitingForYou(who))
503 if (senderFor(id) === sessionId) void notifySystem($, t.notifyTitle, `${who}${c.waiting ? ` · ${c.waiting}` : ''}`, c.hostId)
504 }
505 }
506 isFirstRead = false
507 wasWaiting = waitingNow
508 if (showStatusLine) {
509 const parts = [running && t.statusRunning(running), waiting && t.statusWaiting(waiting)].filter(Boolean)
510 $.ui.status(parts.length ? `agents ${parts.join(' · ')}` : undefined)
511 }
512}
513
514type Entry = { type?: string; timestamp?: string; message?: { content?: unknown } }
515
516const textOf = (content: unknown): string => {
517 if (typeof content === 'string') return content
518 if (!Array.isArray(content)) return ''
519 return content
520 .filter((c: { type?: string; text?: unknown }) => c && c.type === 'text' && typeof c.text === 'string')
521 .map((c: { text: string }) => c.text)
522 .join('\n')
523}
524
525/** What a subagent's transcript says: its task, its last tool calls, its last words. */
526function readTranscript(first: string, tail: string[]): Omit<AgentDetail, 'agentId' | 'isLoading'> {
527 const parse = (line: string): Entry | undefined => {
528 try {
529 return JSON.parse(line) as Entry
530 } catch {
531 return undefined
532 }
533 }
534 const opening = parse(first)
535 const prompt = opening?.type === 'user' ? textOf(opening.message?.content) : ''
536 const tools: AgentDetail['tools'] = []
537 let answer = ''
538 for (const line of tail) {
539 const entry = parse(line)
540 if (entry?.type !== 'assistant' || !Array.isArray(entry.message?.content)) continue
541 const at = entry.timestamp ? new Date(entry.timestamp).toTimeString().slice(0, 8) : undefined
542 for (const block of entry.message.content as { type?: string; name?: string; input?: unknown }[]) {
543 if (block?.type === 'tool_use' && block.name) {
544 const full = describeCall(block.name, block.input)
545 tools.push({ name: block.name, detail: full.slice(block.name.length + 3), at })
546 }
547 }
548 const said = textOf(entry.message.content).trim()
549 if (said) answer = said
550 }
551 return {
552 prompt: prompt ? short(prompt, 400) : undefined,
553 tools: tools.slice(-8),
554 answer: answer ? (answer.length > 1500 ? `${answer.slice(0, 1499)}…` : answer) : undefined,
555 }
556}
557
558/** Reads a subagent's page again when its transcript changed; the latest pick wins. */
559async function loadDetail($: EngineInterface, sessionId: string, agentId: string, isForced: boolean) {
560 const wanted = `${sessionId}/${agentId}`
561 detailWanted = wanted
562 const card = (await read($, board)).sessions.find(c => c.sessionId === sessionId)
563 if (!card) {
564 await update($, detail, () => ({ agentId, isLoading: false, tools: [], error: 'session gone' }))
565 return
566 }
567 const path = `${home}/.claude/projects/${projectDir(card.cwd)}/${sessionId}/subagents/agent-${agentId}.jsonl`
568 try {
569 const stat = await $.fs.stat(path)
570 if (!isForced && detailRead.path === path && detailRead.mtimeMs === stat.mtimeMs) return
571 if (isForced) await update($, detail, () => ({ agentId, isLoading: true, tools: [] }))
572 const lines = await tailOf($, path, stat.size, 120, true)
573 if (detailWanted !== wanted) return
574 const found = readTranscript(lines[0] ?? '', lines.slice(1))
575 detailRead = { path, mtimeMs: stat.mtimeMs }
576 await update($, detail, () => ({ agentId, isLoading: false, ...found }))
577 } catch (err) {
578 if (detailWanted !== wanted) return
579 await update($, detail, () => ({ agentId, isLoading: false, tools: [], error: String(err) }))
580 }
581}
582
583/** The last lines of a file: whole when one read can hold it, else through the shell. */
584async function tailOf($: EngineInterface, path: string, size: number, lines: number, withFirst = false) {
585 if (size <= READ_LIMIT) {
586 const all = (await $.fs.read(path)).trimEnd().split('\n')
587 return withFirst ? [all[0] ?? '', ...all.slice(-lines)] : all.slice(-lines)
588 }
589 // Too big for one read: a seek to its last megabyte, through the shell (scripts/tail.ps1).
590 const run = await $.process.run(
591 ['powershell', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', `${$.plugin.root}/scripts/tail.ps1`],
592 { env: { AW_PATH: path, AW_BYTES: String(1024 * 1024), ...(withFirst ? { AW_FIRST: '1' } : {}) }, timeoutMs: 15_000 },
593 )
594 const out = run.stdout.trimEnd()
595 if (run.exitCode !== 0 || out === '') throw new Error(`tail.ps1 exited ${run.exitCode}: ${run.stderr.trim().slice(0, 200)}`)
596 const all = out.split(/\r?\n/)
597 return withFirst ? [all[0] ?? '', ...all.slice(1).slice(-lines)] : all.slice(-lines)
598}
599
600const timeOf = (iso?: string) => (iso ? new Date(iso).toTimeString().slice(0, 5) : undefined)
601
602/** A session's last exchange: the person's last message, Claude's last words, its last tools. */
603function readConversation(lines: string[]): Omit<SessionConvo, 'sessionId' | 'isLoading'> {
604 let prompt: string | undefined
605 let promptAt: string | undefined
606 let answer: string | undefined
607 let answerAt: string | undefined
608 const tools: SessionConvo['tools'] = []
609 let ctx: number | undefined
610 let model: string | undefined
611 for (const line of lines) {
612 let entry: Entry & { isMeta?: boolean; isSidechain?: boolean; message?: { model?: string; usage?: Usage } }
613 try {
614 entry = JSON.parse(line)
615 } catch {
616 continue
617 }
618 if (entry.isSidechain) continue
619 if (entry.type === 'user' && !entry.isMeta) {
620 const text = textOf(entry.message?.content).trim()
621 // Hook and command output arrives as user rows too, wrapped in tags.
622 if (text && !text.startsWith('<')) {
623 prompt = text
624 promptAt = timeOf(entry.timestamp)
625 }
626 }
627 if (entry.type === 'assistant' && entry.message?.usage) {
628 const u = entry.message.usage
629 ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
630 if (entry.message.model && !entry.message.model.startsWith('<')) model = entry.message.model
631 }
632 if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
633 for (const block of entry.message.content as { type?: string; name?: string; input?: unknown }[]) {
634 if (block?.type === 'tool_use' && block.name) {
635 const full = describeCall(block.name, block.input)
636 tools.push({ name: block.name, detail: full.slice(block.name.length + 3), at: timeOf(entry.timestamp) })
637 }
638 }
639 const said = textOf(entry.message.content).trim()
640 if (said) {
641 answer = said
642 answerAt = timeOf(entry.timestamp)
643 }
644 }
645 }
646 const cut = (s: string | undefined, n: number) => (s && s.length > n ? `${s.slice(0, n - 1)}…` : s)
647 return { prompt: cut(prompt, 700), promptAt, answer: cut(answer, 2500), answerAt, tools: tools.slice(-8), ctx, model }
648}
649
650/** Reads a session's conversation again when its transcript changed, at most every CONVO_MS; the latest pick wins. */
651async function loadConvo($: EngineInterface, sessionId: string, isForced: boolean) {
652 convoWanted = sessionId
653 const card = (await read($, board)).sessions.find(c => c.sessionId === sessionId)
654 if (!card) return
655 const path = `${home}/.claude/projects/${projectDir(card.cwd)}/${sessionId}.jsonl`
656 const now = await $.clock.now()
657 try {
658 const stat = await $.fs.stat(path)
659 const isSame = convoRead.path === path && convoRead.mtimeMs === stat.mtimeMs
660 if (!isForced && (isSame || (convoRead.path === path && now - convoRead.at < CONVO_MS))) return
661 if (isForced) await update($, convo, () => ({ sessionId, isLoading: true, tools: [] }))
662 const found = readConversation(await tailOf($, path, stat.size, 400))
663 if (convoWanted !== sessionId) return
664 convoRead = { path, mtimeMs: stat.mtimeMs, at: now }
665 await update($, convo, () => ({ sessionId, isLoading: false, ...found }))
666 } catch (err) {
667 if (convoWanted !== sessionId) return
668 await update($, convo, () => ({ sessionId, isLoading: false, tools: [], error: String(err) }))
669 }
670}
671
672/** A notification of the system's own, with its sound; a click opens the session. */
673async function notifySystem($: EngineInterface, title: string, body: string, hostId?: string) {
674 const link = hostId ? sessionLink(hostId) : ''
675 if ((await $.env.get('OS')) === 'Windows_NT') {
676 await $.process
677 .run(['powershell', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', `${$.plugin.root}/scripts/notify.ps1`], {
678 env: { AW_TITLE: title, AW_BODY: body, AW_LINK: link },
679 timeoutMs: 15_000,
680 })
681 .catch(() => {})
682 } else if ((await $.env.get('XDG_CURRENT_DESKTOP')) !== undefined) {
683 await $.process.run(['notify-send', '-a', 'Agent Watch', title, body], { timeoutMs: 5_000 }).catch(() => {})
684 } else {
685 await $.process
686 .run(['osascript', '-e', 'on run argv', '-e', 'display notification (item 2 of argv) with title (item 1 of argv) sound name "Glass"', '-e', 'end run', title, body], {
687 timeoutMs: 5_000,
688 })
689 .catch(() => {})
690 }
691}
692
693/** Hands the app's deep link to the system, which gives it back to the app. */
694async function openInApp($: EngineInterface, hostId: string) {
695 const url = sessionLink(hostId)
696 const title = (await read($, board)).sessions.find(c => c.hostId === hostId)?.title
697 $.ui.toast(t.opening(title || hostId))
698 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
699 const argv = isWindows
700 ? ['rundll32', 'url.dll,FileProtocolHandler', url]
701 : (await $.env.get('XDG_CURRENT_DESKTOP')) !== undefined
702 ? ['xdg-open', url]
703 : ['open', url]
704 const run = await $.process.run(argv, { timeoutMs: 10_000 }).catch(err => {
705 $.ui.toast(`agent-watch: ${err}`)
706 return undefined
707 })
708 if (run && run.exitCode !== 0) $.ui.toast(`agent-watch: ${argv[0]} exited ${run.exitCode} ${run.stderr.trim()}`.trim())
709}
710
711/** The person's own choices, made from the pane: they win over the manifest's defaults. */
712async function loadPreferences($: EngineInterface) {
713 const kept = (await $.store.get('preferences').catch(() => undefined)) as
714 | { notifyWaiting?: boolean; statusLine?: boolean; language?: 'en' | 'fr' }
715 | undefined
716 if (typeof kept?.notifyWaiting === 'boolean') notifyWaiting = kept.notifyWaiting
717 if (typeof kept?.statusLine === 'boolean') showStatusLine = kept.statusLine
718 if (kept?.language === 'en' || kept?.language === 'fr') {
719 language = kept.language
720 t = STRINGS[language]
721 }
722}
723
724async function savePreferences($: EngineInterface) {
725 await $.store
726 .set('preferences', { notifyWaiting, statusLine: showStatusLine, language })
727 .catch(err => $.ui.log(`agent-watch: could not save preferences: ${err}`, { to: 'debug' }))
728 $.ui.invalidate('ui.render')
729}
730
731async function tickOnce($: EngineInterface) {
732 await followSessionId($).catch(() => {})
733 await writeOwn($).catch(err => $.ui.log(`agent-watch: could not write: ${err}`, { to: 'debug' }))
734 await readAll($).catch(err => $.ui.log(`agent-watch: could not read: ${err}`, { to: 'debug' }))
735 const at = await read($, route)
736 // The session on screen: the one picked, or the one the wide pane shows by itself.
737 const looking = at.view !== 'list' ? at.sessionId : shownSession
738 if (looking) {
739 const looked = (await read($, board)).sessions.find(c => c.sessionId === looking)
740 const marks = await read($, seen)
741 const newest = Math.max(looked?.since ?? 0, ...(looked?.agents.map(a => a.endedAt ?? 0) ?? []))
742 if (looked && newest > (marks[looking] ?? 0)) {
743 const now = await $.clock.now()
744 await update($, seen, all => ({ ...all, [looking]: now }))
745 }
746 if (at.view !== 'agent') await loadConvo($, looking, false).catch(() => {})
747 }
748 if (at.view === 'agent') await loadDetail($, at.sessionId, at.agentId, false).catch(() => {})
749}
750
751// One pass at a time: a call made while one runs asks for one more pass after it, never a second
752// pass at once (which would race the first and write older data over newer).
753let ticking: Promise<void> | undefined
754let isTickAsked = false
755function tick($: EngineInterface): Promise<void> {
756 if (ticking) {
757 isTickAsked = true
758 return ticking
759 }
760 ticking = (async () => {
761 do {
762 isTickAsked = false
763 // A pass that fails (the module unloading under it, a file gone) is logged, never thrown at
764 // a caller that did not wait for it.
765 await tickOnce($).catch(err => $.ui.log(`agent-watch: a pass failed: ${err}`, { to: 'debug' }))
766 } while (isTickAsked)
767 })().finally(() => {
768 ticking = undefined
769 })
770 return ticking
771}
772
773export const register: Register = (on, options) => {
774 language = options.language === 'fr' ? 'fr' : 'en'
775 t = STRINGS[language]
776 notifyWaiting = options.notifyWaiting !== false
777 showStatusLine = options.statusLine !== false
778 openOnStart = options.openOnStart !== false
779
780 on('session.start', async ($, e, next) => {
781 const started = await next(e)
782 await loadPreferences($)
783 home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
784 folder = `${home}/.claude/agent-watch`
785 sessionId = await $.session.id()
786 cwd = e.cwd
787 const me = await read($, self)
788 if (me.since === 0) await setLoop($, 'idle')
789
790 await $.command.register({
791 name: COMMAND,
792 description: t.commandDescription,
793 argumentHint: '[text]',
794 })
795 $.clock.every(TICK_MS, () => void tick($))
796 await tick($)
797 // The pane as the session list: opened by itself, where the surface seats it beside the transcript.
798 if (openOnStart && e.isInteractive) void $.ui.open({ id: PANE, title: t.paneTitle }).catch(() => {})
799
800 return started
801 })
802
803 on('command.run', { command: COMMAND }, async ($, e) => {
804 await tick($)
805 // Text when asked, or where no surface draws a pane (a `-p` run, the SDK).
806 const surfaces = await $.session.surfaces().catch(() => [])
807 if (e.args.trim() === 'text' || surfaces.length === 0) {
808 const { now, sessions } = await read($, board)
809 return { text: drawText({ now, sessions, mine: sessionId, t }) }
810 }
811 await $.ui.open({ id: PANE, title: t.paneTitle, focus: true })
812
813 return { text: t.paneOpened }
814 })
815
816 // The hooks below sit on the turn's path: they note what changed and let the next pass read the
817 // board, never holding the turn, a dialog or a question for it.
818 on('turn.start', async ($, e, next) => {
819 const me = await read($, self)
820 const title = me.title || (e.text ? short(e.text, 80) : '')
821 await setLoop($, 'running', { title, waiting: undefined, tool: undefined })
822 void tick($)
823
824 return next(e)
825 })
826
827 on('turn.complete', async ($, e, next) => {
828 if (e.agentId === undefined) {
829 await setLoop($, 'idle', { waiting: undefined, tool: undefined })
830 void tick($)
831 }
832
833 return next(e)
834 })
835
836 on('tool.call', async ($, e, next) => {
837 const agent = e.agentId
838 const label = describeTool(e)
839 const asking = askingReason(e.tool)
840 if (agent === undefined) {
841 await setLoop($, asking ? 'waiting' : 'running', { tool: label, toolSince: await $.clock.now(), waiting: asking })
842 } else {
843 await setSelf($, s => ({
844 ...s,
845 agentTools: { ...s.agentTools, [agent]: label },
846 agentWaiting: asking ? { ...s.agentWaiting, [agent]: asking } : s.agentWaiting,
847 }))
848 }
849 if (asking) void tick($)
850
851 try {
852 return await next(e)
853 } finally {
854 if (agent === undefined) {
855 const me = await read($, self)
856 if (me.state === 'waiting' || me.tool === label) {
857 await setLoop($, 'running', { tool: undefined, toolSince: undefined, waiting: undefined })
858 }
859 } else {
860 await setSelf($, s => {
861 const { [agent]: _tool, ...agentTools } = s.agentTools
862 const { [agent]: _waiting, ...agentWaiting } = s.agentWaiting
863 return { ...s, agentTools, agentWaiting }
864 })
865 }
866 }
867 })
868
869 // A permission dialog is open: the loop waits on the person until its tool ends.
870 on('classic.PermissionRequest', async ($, e, next) => {
871 const why = t.permission(e.tool_name)
872 const agent = e.agent_id
873 if (agent === undefined) {
874 await setLoop($, 'waiting', { waiting: why })
875 } else {
876 await setSelf($, s => ({ ...s, agentWaiting: { ...s.agentWaiting, [agent]: why } }))
877 }
878 void tick($)
879
880 return next(e)
881 })
882
883 on('session.end', async ($, e, next) => {
884 // The ending session's card says so; after a /clear or a resume the process goes on, and the
885 // next pass follows it under its new id.
886 await setLoop($, 'ended', { waiting: undefined, tool: undefined })
887 await writeOwn($).catch(() => {})
888
889 return next(e)
890 })
891
892 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
893 const { now, sessions } = await read($, board)
894 const width = Math.max(40, e.props.bodyColumns ?? e.viewport?.columns ?? 80)
895
896 const vm: ViewModel = {
897 now,
898 sessions,
899 filter: await read($, filter),
900 collapsed: await read($, collapsed),
901 route: await read($, route),
902 detail: await read($, detail),
903 convo: await read($, convo),
904 seen: await read($, seen),
905 mine: sessionId,
906 home,
907 width,
908 isTerminal: e.surface === 'terminal',
909 t,
910 version: VERSION,
911 notify: notifyWaiting,
912 statusLine: showStatusLine,
913 }
914 const act: Actions = {
915 setFilter: (f: Filter) => {
916 void update($, route, () => ({ view: 'list' }) as Route)
917 void update($, filter, () => f)
918 },
919 toggle: (id: string) =>
920 void update($, collapsed, list => (list.includes(id) ? list.filter(one => one !== id) : [...list, id])),
921 go: (to: Route, focusKey?: string) => {
922 void update($, route, () => to).then(() =>
923 focusKey ? $.ui.focus({ requestId: PANE, key: focusKey }).catch(() => undefined) : undefined,
924 )
925 if (to.view !== 'list') void $.clock.now().then(now => update($, seen, marks => ({ ...marks, [to.sessionId]: now })))
926 if (to.view === 'agent') void loadDetail($, to.sessionId, to.agentId, true)
927 if (to.view === 'session') void loadConvo($, to.sessionId, true)
928 },
929 showing: (id: string | undefined) => {
930 // Drawn without a pick: the next pass loads its conversation and marks it seen.
931 if (id !== shownSession) {
932 shownSession = id
933 void tick($)
934 }
935 },
936 openSession: (hostId: string) => void openInApp($, hostId),
937 refresh: () => void tick($),
938 toggleLanguage: () => {
939 language = language === 'fr' ? 'en' : 'fr'
940 t = STRINGS[language]
941 $.ui.toast(t.languageIs)
942 void savePreferences($)
943 },
944 toggleNotify: () => {
945 notifyWaiting = !notifyWaiting
946 $.ui.toast(t.notifyIs(notifyWaiting))
947 void savePreferences($)
948 },
949 toggleStatusLine: () => {
950 showStatusLine = !showStatusLine
951 if (!showStatusLine) $.ui.status(undefined)
952 void savePreferences($)
953 },
954 }
955
956 return drawApp($.ui.resolve(e) as unknown as Kit, vm, act)
957 })
958}
959hooks/strings.ts 283 lines1import type { LoopState } from '../types'
2
3/** Every word the mod shows, per language. */
4export type Strings = {
5 state: Record<LoopState, string>
6 badge: Record<LoopState, string>
7 filterName: Record<LoopState, string>
8 question: string
9 planApproval: string
10 permission: (tool: string) => string
11 waitingFallback: string
12 quietFor: (ago: string) => string
13 idleFor: (ago: string) => string
14 lastSeen: (ago: string) => string
15 newSession: string
16 here: string
17 noSessions: string
18 noMatch: string
19 isWaitingForYou: (who: string) => string
20 statusRunning: (n: number) => string
21 statusWaiting: (n: number) => string
22 commandDescription: string
23 paneOpened: string
24 paneTitle: string
25 subtitle: string
26 autoRefresh: string
27 since: (ago: string) => string
28 endedAgo: (ago: string) => string
29 inputRequired: string
30 waitingForYourResponse: string
31 waitingForNextMessage: string
32 noAgents: string
33 filters: string
34 allSessions: string
35 settings: string
36 language: string
37 languageName: string
38 notifications: string
39 statusLine: string
40 on: string
41 off: string
42 shortcuts: string
43 refresh: string
44 filterKeys: string
45 focusKeys: string
46 closeKeys: string
47 openSource: string
48 footerRefresh: string
49 footerNotifications: (isOn: boolean) => string
50 back: string
51 openInApp: string
52 open: string
53 folder: string
54 stateLabel: string
55 duration: string
56 waitingReason: string
57 currentTool: string
58 sessionId: string
59 agentsTitle: (n: number) => string
60 task: string
61 lastTools: string
62 lastAnswer: string
63 loading: string
64 nothingYet: string
65 unreadable: string
66 hint: string
67 conversation: string
68 you: string
69 claude: string
70 recentTools: string
71 readingConvo: string
72 noConvo: string
73 notifyTitle: string
74 silentFor: (ago: string) => string
75 tokensOut: (n: string) => string
76 tokensCtx: (n: string) => string
77 textHeader: (running: number, waiting: number, idle: number) => string
78 done: string
79 word: { blocked: string; working: string; done: string; idle: string; ended: string }
80 agentsHeader: string
81 working: string
82 sessionsTitle: string
83 keys: string
84 next: string
85 previous: string
86 pickOne: string
87 thisSession: string
88 opening: (title: string) => string
89 notifyOn: string
90 notifyOff: string
91 notifyIs: (isOn: boolean) => string
92 languageIs: string
93 details: string
94 sessionsCount: (n: number) => string
95}
96
97export const STRINGS: Record<'en' | 'fr', Strings> = {
98 en: {
99 state: { running: 'running', waiting: 'waiting for you', idle: 'idle', ended: 'ended' },
100 badge: { running: 'RUNNING', waiting: 'WAITING', idle: 'IDLE', ended: 'ENDED' },
101 filterName: { running: 'Running', waiting: 'Waiting', idle: 'Idle', ended: 'Ended' },
102 question: 'question',
103 planApproval: 'plan approval',
104 permission: tool => `permission ${tool}`,
105 waitingFallback: 'waiting',
106 quietFor: ago => `quiet for ${ago}`,
107 idleFor: ago => `waiting for your message for ${ago}`,
108 lastSeen: ago => `last seen ${ago} ago`,
109 newSession: 'new session',
110 here: ' (here)',
111 noSessions: 'No sessions found.',
112 noMatch: 'No session matches this filter.',
113 isWaitingForYou: who => `${who} is waiting for you`,
114 statusRunning: n => `▶ ${n} running`,
115 statusWaiting: n => `◆ ${n} waiting`,
116 commandDescription: 'Open the panel of every session and subagent, running or waiting',
117 paneOpened: 'Agent Watch opened.',
118 paneTitle: 'Agent Watch',
119 subtitle: 'Monitor your Claude Code agents in real time',
120 autoRefresh: 'Auto refresh',
121 since: ago => `since ${ago}`,
122 endedAgo: ago => `ended ${ago} ago`,
123 inputRequired: 'Permission / input required',
124 waitingForYourResponse: 'Claude is waiting for your response',
125 waitingForNextMessage: 'Turn over, waiting for your next message',
126 noAgents: 'No subagents',
127 filters: 'FILTERS',
128 allSessions: 'All sessions',
129 settings: 'SETTINGS',
130 language: 'Language',
131 languageName: 'English',
132 notifications: 'Notifications',
133 statusLine: 'Status line',
134 on: 'On',
135 off: 'Off',
136 shortcuts: 'SHORTCUTS',
137 refresh: 'Refresh',
138 filterKeys: 'Filters',
139 focusKeys: 'Focus the pane',
140 closeKeys: 'Close',
141 openSource: 'Open source · MIT License',
142 footerRefresh: 'Live',
143 footerNotifications: isOn => `Notifications: ${isOn ? 'enabled' : 'disabled'}`,
144 back: '← Back',
145 openInApp: 'Open in the app ↗',
146 open: 'Open ↗',
147 folder: 'Folder',
148 stateLabel: 'State',
149 duration: 'For',
150 waitingReason: 'Waiting on',
151 currentTool: 'Doing',
152 sessionId: 'Session',
153 agentsTitle: n => `SUBAGENTS · ${n}`,
154 task: 'TASK',
155 lastTools: 'LAST TOOL CALLS',
156 lastAnswer: 'LAST WORDS',
157 loading: 'Reading its transcript…',
158 nothingYet: 'Nothing yet.',
159 unreadable: 'Its transcript could not be read.',
160 hint: 'Click a session for its details, an agent for its activity.',
161 conversation: 'CONVERSATION',
162 you: 'You',
163 claude: 'Claude',
164 recentTools: 'RECENT TOOL CALLS',
165 readingConvo: 'Reading the conversation…',
166 noConvo: 'No conversation yet.',
167 notifyTitle: 'A Claude session is waiting for you',
168 silentFor: ago => `silent for ${ago}`,
169 tokensOut: n => `${n} out`,
170 tokensCtx: n => `ctx ${n}`,
171 textHeader: (r, w, i) => `Agent Watch — ${r} running · ${w} waiting · ${i} idle`,
172 done: 'DONE',
173 word: { blocked: 'blocked', working: 'working', done: 'done', idle: 'idle', ended: 'ended' },
174 agentsHeader: 'agents',
175 working: 'Working…',
176 sessionsTitle: 'SESSIONS',
177 keys: 'click or j/k to move · o open in the app · b back',
178 next: 'j ↓',
179 previous: 'k ↑',
180 pickOne: 'Pick a session on the left.',
181 thisSession: 'this session',
182 opening: title => `Opening “${title}” in the app…`,
183 notifyOn: '🔔 Notifications ON',
184 notifyOff: '🔕 Notifications OFF',
185 notifyIs: isOn => (isOn ? '🔔 Notifications are on: you will be told when a session waits for you' : '🔕 Notifications are off'),
186 languageIs: 'Agent Watch now speaks English',
187 details: 'Details',
188 sessionsCount: n => `${n} session${n > 1 ? 's' : ''}`,
189 },
190 fr: {
191 state: { running: 'en cours', waiting: 'attend ta réponse', idle: 'inactif', ended: 'terminé' },
192 badge: { running: 'EN COURS', waiting: 'EN ATTENTE', idle: 'INACTIF', ended: 'TERMINÉ' },
193 filterName: { running: 'En cours', waiting: 'En attente', idle: 'Inactif', ended: 'Terminé' },
194 question: 'question',
195 planApproval: 'validation du plan',
196 permission: tool => `permission ${tool}`,
197 waitingFallback: 'en attente',
198 quietFor: ago => `silencieux depuis ${ago}`,
199 idleFor: ago => `attend ton message depuis ${ago}`,
200 lastSeen: ago => `vu il y a ${ago}`,
201 newSession: 'nouvelle session',
202 here: ' (ici)',
203 noSessions: 'Aucune session trouvée.',
204 noMatch: 'Aucune session pour ce filtre.',
205 isWaitingForYou: who => `${who} attend ta réponse`,
206 statusRunning: n => `▶ ${n} en cours`,
207 statusWaiting: n => `◆ ${n} en attente`,
208 commandDescription: 'Ouvre le panneau de toutes les sessions et sous-agents, en cours ou en attente',
209 paneOpened: 'Agent Watch ouvert.',
210 paneTitle: 'Agent Watch',
211 subtitle: 'Surveille tes agents Claude Code en temps réel',
212 autoRefresh: 'Actualisation',
213 since: ago => `depuis ${ago}`,
214 endedAgo: ago => `terminé il y a ${ago}`,
215 inputRequired: 'Permission / réponse requise',
216 waitingForYourResponse: 'Claude attend ta réponse',
217 waitingForNextMessage: 'Tour terminé, attend ton prochain message',
218 noAgents: 'Aucun sous-agent',
219 filters: 'FILTRES',
220 allSessions: 'Toutes les sessions',
221 settings: 'RÉGLAGES',
222 language: 'Langue',
223 languageName: 'Français',
224 notifications: 'Notifications',
225 statusLine: 'Ligne d’état',
226 on: 'Oui',
227 off: 'Non',
228 shortcuts: 'RACCOURCIS',
229 refresh: 'Actualiser',
230 filterKeys: 'Filtres',
231 focusKeys: 'Activer le panneau',
232 closeKeys: 'Fermer',
233 openSource: 'Open source · Licence MIT',
234 footerRefresh: 'En direct',
235 footerNotifications: isOn => `Notifications : ${isOn ? 'activées' : 'désactivées'}`,
236 back: '← Retour',
237 openInApp: 'Ouvrir dans l’app ↗',
238 open: 'Ouvrir ↗',
239 folder: 'Dossier',
240 stateLabel: 'État',
241 duration: 'Depuis',
242 waitingReason: 'Attend',
243 currentTool: 'En train de',
244 sessionId: 'Session',
245 agentsTitle: n => `SOUS-AGENTS · ${n}`,
246 task: 'TÂCHE',
247 lastTools: 'DERNIERS OUTILS',
248 lastAnswer: 'DERNIERS MOTS',
249 loading: 'Lecture du transcript…',
250 nothingYet: 'Rien pour l’instant.',
251 unreadable: 'Impossible de lire son transcript.',
252 hint: 'Clique une session pour ses détails, un agent pour son activité.',
253 conversation: 'CONVERSATION',
254 you: 'Toi',
255 claude: 'Claude',
256 recentTools: 'DERNIERS OUTILS',
257 readingConvo: 'Lecture de la conversation…',
258 noConvo: 'Pas encore de conversation.',
259 notifyTitle: 'Une session Claude attend ta réponse',
260 silentFor: ago => `silencieux depuis ${ago}`,
261 tokensOut: n => `${n} générés`,
262 tokensCtx: n => `ctx ${n}`,
263 textHeader: (r, w, i) => `Agent Watch — ${r} en cours · ${w} en attente · ${i} inactifs`,
264 done: 'FINI',
265 word: { blocked: 'bloquée', working: 'en cours', done: 'finie', idle: 'inactive', ended: 'terminée' },
266 agentsHeader: 'agents',
267 working: 'Au travail…',
268 sessionsTitle: 'SESSIONS',
269 keys: 'clic ou j/k pour naviguer · o ouvrir dans l’app · b retour',
270 next: 'j ↓',
271 previous: 'k ↑',
272 pickOne: 'Choisis une session à gauche.',
273 thisSession: 'cette session',
274 opening: title => `Ouverture de « ${title} » dans l’app…`,
275 notifyOn: '🔔 Notifications ON',
276 notifyOff: '🔕 Notifications OFF',
277 notifyIs: isOn => (isOn ? '🔔 Notifications activées : tu seras prévenu quand une session t’attend' : '🔕 Notifications désactivées'),
278 languageIs: 'Agent Watch parle maintenant français',
279 details: 'Détails',
280 sessionsCount: n => `${n} session${n > 1 ? 's' : ''}`,
281 },
282}
283hooks/view.tsx 764 lines1import type { Elements, RenderElement } from 'claude-code'
2
3import type { AgentCard, AgentDetail, Filter, LoopState, Route, SessionCard, SessionConvo, Tokens } from '../types'
4import type { Strings } from './strings'
5
6export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Link' | 'Markdown'>
7
8/** What the pane draws from, gathered by the render hook. */
9export type ViewModel = {
10 now: number
11 sessions: SessionCard[]
12 filter: Filter
13 collapsed: string[]
14 route: Route
15 detail: AgentDetail | null
16 convo: SessionConvo | null
17 seen: Record<string, number>
18 mine: string
19 home: string
20 width: number
21 isTerminal: boolean
22 t: Strings
23 version: string
24 notify: boolean
25 statusLine: boolean
26}
27
28/** What the pane's controls do. */
29export type Actions = {
30 setFilter: (filter: Filter) => void
31 toggle: (sessionId: string) => void
32 /** Shows a page; `focusKey` is the element of the new page that takes the pane's focus. */
33 go: (route: Route, focusKey?: string) => void
34 /** The session the wide pane shows without one being picked, so its page loads. */
35 showing: (sessionId: string | undefined) => void
36 openSession: (hostId: string) => void
37 refresh: () => void
38 toggleLanguage: () => void
39 toggleNotify: () => void
40 toggleStatusLine: () => void
41}
42
43const RANK: Record<LoopState, number> = { waiting: 0, running: 1, idle: 2, ended: 3 }
44const FILTERS: Filter[] = ['all', 'running', 'waiting', 'idle', 'ended']
45// The session list, herdr's way: a column of its own beside the selected session.
46const SIDEBAR = 40
47// The pane is wide enough for the list beside the selected session from here.
48const WIDE = 96
49
50/** Colours by the app's theme keys, so the pane reads as part of it. */
51const THEME = {
52 running: 'success',
53 waiting: 'warning',
54 idle: 'inactive',
55 ended: 'error',
56 accent: 'claude',
57 text: undefined,
58 dim: 'inactive',
59 faint: 'inactive',
60 line: 'inactive',
61 card: undefined,
62 panel: undefined,
63}
64type Palette = Record<keyof typeof THEME, string | undefined>
65
66export const agentState = (status: string, waiting?: string): LoopState => {
67 if (waiting) return 'waiting'
68 if (status === 'running' || status === 'pending') return 'running'
69 if (status === 'waiting') return 'waiting'
70 if (status === 'idle') return 'idle'
71 return 'ended'
72}
73
74/** The most urgent of a session and its subagents: what its card says. */
75export const liveState = (c: SessionCard): LoopState => {
76 let best = c.state
77 for (const a of c.agents) {
78 const s = agentState(a.status, a.waiting)
79 if (RANK[s] < RANK[best]) best = s
80 }
81 return best
82}
83
84export const clock = (ms: number) => {
85 const s = Math.max(0, Math.round(ms / 1000))
86 if (s < 60) return `${s}s`
87 const m = Math.floor(s / 60)
88 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
89 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
90}
91
92// A tool call running this long, and a running agent silent this long, are flagged.
93export const SLOW_TOOL_MS = 30_000
94export const SILENT_MS = 120_000
95// Under this, a running agent writing nothing is just thinking.
96const QUIET_MS = 30_000
97
98/** `12.3k`, `1.2M`. */
99export const count = (n: number) =>
100 n < 1000 ? String(n) : n < 1_000_000 ? `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k` : `${(n / 1_000_000).toFixed(1)}M`
101
102export const tokensText = (tk: Tokens | undefined, t: Strings) =>
103 !tk ? '' : [tk.out !== undefined ? t.tokensOut(count(tk.out)) : '', tk.ctx !== undefined ? t.tokensCtx(count(tk.ctx)) : ''].filter(Boolean).join(' · ')
104
105/** What an agent is doing now, and whether it deserves a look (a slow tool, a long silence). */
106export function activityOf(a: AgentCard, now: number, t: Strings): { text?: string; isAlert: boolean } {
107 const state = agentState(a.status, a.waiting)
108 if (state === 'ended') return { text: tokensText(a.tokens, t) || undefined, isAlert: false }
109 if (a.waiting) return { text: a.waiting, isAlert: true }
110 if (a.tool) {
111 const took = a.toolSince === undefined ? undefined : now - a.toolSince
112 const isSlow = took !== undefined && took > SLOW_TOOL_MS
113 return { text: `${a.tool}${took !== undefined ? ` · ${clock(took)}` : ''}${isSlow ? ' ⚠' : ''}`, isAlert: isSlow }
114 }
115 const quiet = a.lastActivity === undefined ? 0 : now - a.lastActivity
116 if (quiet > SILENT_MS) return { text: `⚠ ${t.silentFor(clock(quiet))}`, isAlert: true }
117 if (quiet > QUIET_MS) return { text: t.quietFor(clock(quiet)), isAlert: false }
118 return { isAlert: false }
119}
120
121const short = (text: string, max: number) => {
122 const line = text.replace(/\s+/g, ' ').trim()
123 return max > 1 && line.length > max ? `${line.slice(0, max - 1)}…` : line
124}
125
126const prettyPath = (path: string, home: string) => {
127 const p = path.replace(/\\/g, '/').replace(/\/$/, '')
128 return home && p.toLowerCase().startsWith(home.toLowerCase()) ? `~${p.slice(home.length)}` : p
129}
130
131/** The deep link the desktop app answers by opening that session. */
132const language = (t: Strings) => (t.languageName === 'Français' ? 'FR' : 'EN')
133
134/** The working mark. It stands still: a spinner would redraw the pane, and drop clicks, every second. */
135export const spinner = (_now: number) => '●'
136
137export const sessionLink = (hostId: string) => `claude://code/continue?session=${encodeURIComponent(hostId)}`
138
139export function drawApp(el: Kit, vm: ViewModel, act: Actions) {
140 const { Box, Text, Button, Link, Markdown } = el
141 const { t, now } = vm
142 const P: Palette = THEME
143 const isWide = vm.width >= WIDE
144 const main = isWide ? vm.width - SIDEBAR - 3 : vm.width
145 const route = vm.route
146
147 const counts: Record<LoopState, number> = { running: 0, waiting: 0, idle: 0, ended: 0 }
148 for (const c of vm.sessions) counts[liveState(c)] += 1
149 let loopsRunning = 0
150 let loopsWaiting = 0
151 for (const c of vm.sessions) {
152 for (const s of [c.state, ...c.agents.map(a => agentState(a.status, a.waiting))]) {
153 if (s === 'running') loopsRunning += 1
154 if (s === 'waiting') loopsWaiting += 1
155 }
156 }
157
158 // ── atoms ─────────────────────────────────────────────────────────────
159 const badge = (state: LoopState, label: string) =>
160 vm.isTerminal ? (
161 <Text color={P[state]} inverse>
162 {` ${label} `}
163 </Text>
164 ) : (
165 <Text color={P[state]} bold>
166 {label}
167 </Text>
168 )
169 const dot = (state: LoopState) => <Text color={P[state]}>● </Text>
170 const label = (text: string) => (
171 <Text color={P.dim} bold>
172 {text}
173 </Text>
174 )
175 // A rule of box-drawing dashes reads only where a cell is a glyph wide: the terminal.
176 const rule = () => (vm.isTerminal ? <Text color={P.line}>{'─'.repeat(Math.max(0, Math.min(vm.width, 400)))}</Text> : null)
177 const openLink = (c: SessionCard, text: string) => {
178 const hostId = c.hostId
179 if (c.sessionId === vm.mine) return <Text color={P.faint}>{t.thisSession}</Text>
180 return hostId ? (
181 <Button plain key={`open-${c.sessionId}`} label={text} onPress={() => act.openSession(hostId)} />
182 ) : null
183 }
184 const card = (key: string, isAlert: boolean, children: RenderElement) => (
185 <Box
186 key={key}
187 flexDirection="column"
188 borderStyle="round"
189 borderColor={isAlert ? P.waiting : P.line}
190 paddingX={1}
191 marginBottom={1}
192 >
193 {children}
194 </Box>
195 )
196 // A page change takes away the button just pressed, and the pane's focus with it: the next click
197 // would only give it back. The new page's own button takes the focus instead.
198 const nav = (to: Route) =>
199 act.go(
200 to,
201 to.view === 'agent'
202 ? 'back'
203 : to.view === 'session'
204 ? isWide
205 ? `more-${to.sessionId}`
206 : 'back'
207 : route.view !== 'list'
208 ? `more-${route.sessionId}`
209 : undefined,
210 )
211 const back = (to: Route) => <Button key="back" hotkey="b" label={t.back} onPress={() => nav(to)} />
212
213 // ── header ────────────────────────────────────────────────────────────
214 const header = (
215 <Box key="header" flexDirection="column">
216 <Box justifyContent="space-between" flexWrap="wrap" columnGap={2}>
217 <Text>
218 <Text color={P.accent}>◉ </Text>
219 <Text bold color={P.text}>
220 AGENT WATCH
221 </Text>
222 <Text color={P.faint}>{` ${t.sessionsCount(vm.sessions.length)}`}</Text>
223 </Text>
224 <Box columnGap={2} flexWrap="wrap" alignItems="center">
225 {(['waiting', 'running', 'idle'] as const).map(s => {
226 const glyph = s === 'waiting' ? '◆' : s === 'running' ? '●' : '○'
227 const isOn = vm.filter === s
228 return (
229 <Box key={`count-box-${s}`}>
230 <Text color={counts[s] === 0 ? P.faint : P[s]}>{`${glyph} `}</Text>
231 <Button
232 key={`count-${s}`}
233 plain
234 label={`${counts[s]} ${t.word[s === 'waiting' ? 'blocked' : s === 'running' ? 'working' : 'idle']}${isOn ? ' ✕' : ''}`}
235 dimColor={counts[s] === 0 && !isOn}
236 onPress={() => act.setFilter(isOn ? 'all' : s)}
237 />
238 </Box>
239 )
240 })}
241 <Button key="notify-top" plain label={vm.notify ? '🔔' : '🔕'} dimColor={!vm.notify} onPress={act.toggleNotify} />
242 </Box>
243 </Box>
244 {rule()}
245 </Box>
246 )
247
248 // ── one agent row (clickable) ─────────────────────────────────────────
249 const agentRow = (c: SessionCard, a: AgentCard) => {
250 const state = agentState(a.status, a.waiting)
251 const elapsed =
252 a.startedAt === undefined ? '' : clock((state === 'ended' ? (a.endedAt ?? now) : now) - a.startedAt)
253 const activity = activityOf(a, now, t)
254 return (
255 <Box key={`a-${a.id}`} flexDirection="column">
256 <Box justifyContent="space-between" columnGap={1}>
257 <Box flexShrink={1}>
258 {dot(state)}
259 <Button
260 plain
261 key={`agent-${c.sessionId}-${a.id}`}
262 label={short(`${a.type} · ${a.label}`, 70)}
263 onPress={() => nav({ view: 'agent', sessionId: c.sessionId, agentId: a.id })}
264 />
265 </Box>
266 <Box columnGap={1} flexShrink={0}>
267 {badge(state, t.filterName[state])}
268 <Text color={P.dim}>{elapsed.padStart(7)}</Text>
269 </Box>
270 </Box>
271 {activity.text && (
272 <Text color={activity.isAlert ? P.waiting : P.faint} wrap="truncate-end">
273 {` ${state === 'ended' ? '' : '▸ '}${activity.text}`}
274 </Text>
275 )}
276 </Box>
277 )
278 }
279
280 const statusBlock = (c: SessionCard) =>
281 c.state === 'waiting' ? (
282 <Box borderStyle="round" borderColor={P.waiting} paddingX={1} flexDirection="column">
283 <Text color={P.waiting} bold wrap="truncate-end">
284 {`⚠ ${t.inputRequired}`}
285 </Text>
286 <Text color={P.dim} wrap="truncate-end">
287 {c.waiting ? `${c.waiting} · ${t.waitingForYourResponse}` : t.waitingForYourResponse}
288 </Text>
289 </Box>
290 ) : c.state === 'running' ? (
291 <Text wrap="truncate-end">
292 <Text color={P.running}>▸ </Text>
293 <Text color={c.toolSince !== undefined && now - c.toolSince > SLOW_TOOL_MS ? P.waiting : P.dim}>
294 {c.tool
295 ? `${c.tool}${c.toolSince !== undefined ? ` · ${clock(now - c.toolSince)}` : ''}${c.toolSince !== undefined && now - c.toolSince > SLOW_TOOL_MS ? ' ⚠' : ''}`
296 : t.state.running}
297 </Text>
298 </Text>
299 ) : c.state === 'idle' ? (
300 <Text color={P.faint} wrap="truncate-end">
301 {`○ ${t.waitingForNextMessage}`}
302 </Text>
303 ) : (
304 <Text color={P.faint} wrap="truncate-end">
305 {`× ${t.lastSeen(clock(now - c.updatedAt))}`}
306 </Text>
307 )
308
309 // ── session page ──────────────────────────────────────────────────────
310 const field = (name: string, value: string | null, key: string) =>
311 value ? (
312 <Box key={key} columnGap={1}>
313 <Text color={P.dim}>{`${name.padEnd(12)}`}</Text>
314 <Text color={P.text} wrap="truncate-end">
315 {value}
316 </Text>
317 </Box>
318 ) : null
319 const terminal = (c: SessionCard) => {
320 const v = vm.convo && vm.convo.sessionId === c.sessionId ? vm.convo : null
321 const state = c.state
322 const lastTool = v?.tools.at(-1)
323 return (
324 <Box
325 key="terminal"
326 flexDirection="column"
327 borderStyle="round"
328 borderColor={state === 'waiting' ? P.waiting : P.line}
329 paddingX={1}
330 marginBottom={1}
331 >
332 {!v || (v.isLoading && !v.prompt && !v.answer && v.tools.length === 0) ? (
333 <Text color={P.dim}>{t.readingConvo}</Text>
334 ) : v.error || (!v.prompt && !v.answer && v.tools.length === 0) ? (
335 <Text color={P.faint}>{t.noConvo}</Text>
336 ) : (
337 <Box flexDirection="column">
338 {v.prompt ? (
339 <Text key="prompt" wrap="wrap">
340 <Text color={P.accent} bold>
341 {'❯ '}
342 </Text>
343 <Text color={P.text}>{v.prompt}</Text>
344 <Text color={P.faint}>{v.promptAt ? ` ${v.promptAt}` : ''}</Text>
345 </Text>
346 ) : null}
347 {v.tools.length > 0 ? (
348 <Box key="tools" flexDirection="column" marginTop={1}>
349 {v.tools.map((tool, i) => {
350 const isLive = state === 'running' && tool === lastTool
351 return (
352 <Text key={`ct-${i}`} wrap="truncate-end">
353 <Text color={isLive ? P.waiting : P.running}>{isLive ? `${spinner(now)} ` : '⏺ '}</Text>
354 <Text bold color={P.text}>
355 {tool.name}
356 </Text>
357 <Text color={P.dim}>{tool.detail ? `(${tool.detail})` : ''}</Text>
358 </Text>
359 )
360 })}
361 </Box>
362 ) : null}
363 {v.answer ? (
364 <Box key="answer" flexDirection="column" marginTop={1}>
365 <Markdown key="answer" text={v.answer} />
366 </Box>
367 ) : null}
368 </Box>
369 )}
370 {state === 'running' ? (
371 <Text key="working" color={P.accent}>
372 {`${spinner(now)} ${t.working} `}
373 <Text color={P.faint}>{`(${clock(now - (c.toolSince ?? c.since))}${c.tool ? ` · ${c.tool}` : ''})`}</Text>
374 </Text>
375 ) : null}
376 {state === 'waiting' ? (
377 <Box key="ask" flexDirection="column" borderStyle="round" borderColor={P.waiting} paddingX={1} marginTop={1}>
378 <Text color={P.waiting} bold>{`◆ ${t.inputRequired}`}</Text>
379 <Text color={P.dim}>{c.waiting ? `${c.waiting} · ${t.waitingForYourResponse}` : t.waitingForYourResponse}</Text>
380 </Box>
381 ) : null}
382 <Text key="status" color={P.faint} wrap="truncate-end">
383 {[
384 prettyPath(c.cwd, vm.home),
385 c.branch ? `⎇ ${c.branch}` : '',
386 v?.model ? v.model.replace(/^claude-/, '') : '',
387 v?.ctx !== undefined ? `ctx ${count(v.ctx)}` : '',
388 ]
389 .filter(Boolean)
390 .join(' · ')}
391 </Text>
392 </Box>
393 )
394 }
395
396 const sessionPage = (c: SessionCard) => {
397 const state = liveState(c)
398 const word = state === 'waiting' ? t.word.blocked : state === 'running' ? t.word.working : state === 'ended' ? t.word.ended : t.word.idle
399 return (
400 <Box key="session" flexDirection="column" width={main}>
401 <Box justifyContent="space-between" columnGap={2} marginBottom={1}>
402 <Box flexShrink={1} columnGap={1}>
403 {isWide ? null : back({ view: 'list' })}
404 <Text color={state === 'running' ? P.running : state === 'waiting' ? P.waiting : P.dim}>
405 {state === 'running' ? spinner(now) : state === 'waiting' ? '◆' : '○'}
406 </Text>
407 <Text bold color={P.text} wrap="truncate-end">
408 {c.title || t.newSession}
409 </Text>
410 <Text color={state === 'waiting' ? P.waiting : P.faint}>{`${word} · ${clock(now - c.since)}`}</Text>
411 </Box>
412 {openLink(c, t.openInApp)}
413 </Box>
414 {terminal(c)}
415 {c.agents.length > 0 ? (
416 <Box key="agents" flexDirection="column">
417 {label(t.agentsTitle(c.agents.length))}
418 <Box flexDirection="column" borderStyle="round" borderColor={P.line} paddingX={1}>
419 {c.agents.map(a => agentRow(c, a))}
420 </Box>
421 </Box>
422 ) : null}
423 </Box>
424 )
425 }
426
427 // ── agent page ────────────────────────────────────────────────────────
428 const agentPage = (c: SessionCard, a: AgentCard) => {
429 const state = agentState(a.status, a.waiting)
430 const d = vm.detail && vm.detail.agentId === a.id ? vm.detail : null
431 const elapsed =
432 a.startedAt === undefined ? null : clock((state === 'ended' ? (a.endedAt ?? now) : now) - a.startedAt)
433 return (
434 <Box key="agent" flexDirection="column" width={main}>
435 <Box justifyContent="space-between" marginBottom={1}>
436 {back({ view: 'session', sessionId: c.sessionId })}
437 {openLink(c, t.openInApp)}
438 </Box>
439 {card(
440 'agent-card',
441 state === 'waiting',
442 <Box flexDirection="column">
443 <Box columnGap={1}>
444 {dot(state)}
445 <Text bold color={P.text} wrap="truncate-end">
446 {`${a.type} · ${a.label}`}
447 </Text>
448 {badge(state, t.badge[state])}
449 </Box>
450 <Box flexDirection="column" marginTop={1}>
451 {field(t.sessionId, c.title || c.sessionId, 'g-session')}
452 {field(t.duration, elapsed, 'g-elapsed')}
453 {field(t.currentTool, state === 'ended' ? null : (activityOf(a, now, t).text ?? null), 'g-tool')}
454 {field('Tokens', tokensText(a.tokens, t) || null, 'g-tokens')}
455 </Box>
456 </Box>,
457 )}
458 {!d || d.isLoading ? (
459 <Text color={P.dim}>{t.loading}</Text>
460 ) : d.error ? (
461 <Text color={P.ended}>{t.unreadable}</Text>
462 ) : (
463 <Box flexDirection="column">
464 {d.prompt ? (
465 <Box flexDirection="column" marginBottom={1}>
466 {label(t.task)}
467 <Text color={P.text}>{d.prompt}</Text>
468 </Box>
469 ) : null}
470 {label(t.lastTools)}
471 <Box flexDirection="column" borderStyle="round" borderColor={P.line} paddingX={1} marginBottom={1}>
472 {d.tools.length === 0 ? (
473 <Text color={P.faint}>{t.nothingYet}</Text>
474 ) : (
475 d.tools.map((tool, i) => (
476 <Text key={`tool-${i}`} wrap="truncate-end">
477 <Text color={P.running}>▸ </Text>
478 <Text color={P.text} bold>
479 {tool.name}
480 </Text>
481 <Text color={P.dim}>{tool.detail ? ` ${tool.detail}` : ''}</Text>
482 </Text>
483 ))
484 )}
485 </Box>
486 {label(t.lastAnswer)}
487 <Text color={d.answer ? P.text : P.faint}>{d.answer ?? t.nothingYet}</Text>
488 </Box>
489 )}
490 </Box>
491 )
492 }
493
494 // ── the list, herdr's way: by project, one line a session ────────────
495 /** Finished, or turned idle, since you last looked at it. */
496 const isUnseen = (c: SessionCard) =>
497 c.sessionId !== vm.mine &&
498 (c.state === 'idle' || c.state === 'ended') &&
499 c.since > (vm.seen[c.sessionId] ?? Number.POSITIVE_INFINITY)
500 const isAgentUnseen = (c: SessionCard, a: AgentCard) =>
501 agentState(a.status, a.waiting) === 'ended' && (a.endedAt ?? 0) > (vm.seen[c.sessionId] ?? Number.POSITIVE_INFINITY)
502 type Mark = { glyph: string; color: string | undefined }
503 const markOf = (state: LoopState, isDone: boolean): Mark =>
504 state === 'waiting'
505 ? { glyph: '◆', color: P.waiting }
506 : state === 'running'
507 ? { glyph: '●', color: P.running }
508 : isDone
509 ? { glyph: '✓', color: P.accent }
510 : { glyph: state === 'ended' ? '×' : '○', color: P.dim }
511 const sessionMark = (c: SessionCard) => markOf(liveState(c), isUnseen(c) || c.agents.some(a => isAgentUnseen(c, a)))
512
513 const shown = vm.sessions.filter(c => vm.filter === 'all' || liveState(c) === vm.filter)
514 const projectOf = (c: SessionCard) => c.cwd.replace(/\\/g, '/').replace(/\/$/, '').split('/').filter(Boolean).at(-1) ?? c.cwd
515 const projects: { name: string; sessions: SessionCard[] }[] = []
516 for (const c of shown) {
517 const name = projectOf(c)
518 const group = projects.find(p => p.name === name)
519 if (group) group.sessions.push(c)
520 else projects.push({ name, sessions: [c] })
521 }
522 const ordered = projects.flatMap(p => p.sessions)
523 const selectedId =
524 route.view !== 'list'
525 ? route.sessionId
526 : isWide
527 ? (ordered.find(c => c.sessionId !== vm.mine) ?? ordered[0])?.sessionId
528 : undefined
529 const selectedAt = ordered.findIndex(c => c.sessionId === selectedId)
530 act.showing(route.view === 'list' ? selectedId : undefined)
531 const step = (by: number) => {
532 const next = ordered[Math.min(ordered.length - 1, Math.max(0, (selectedAt < 0 ? -1 : selectedAt) + by))]
533 if (next) nav({ view: 'session', sessionId: next.sessionId })
534 }
535
536 const listWidth = isWide ? SIDEBAR : vm.width
537 const wordOf = (state: LoopState, isDone: boolean) =>
538 state === 'waiting' ? t.word.blocked : state === 'running' ? t.word.working : isDone ? t.word.done : state === 'ended' ? t.word.ended : t.word.idle
539 const glyphOf = (state: LoopState, isDone: boolean): Mark =>
540 state === 'running' ? { glyph: spinner(now), color: P.running } : markOf(state, isDone)
541
542 const sessionRow = (c: SessionCard) => {
543 const state = liveState(c)
544 const isDone = isUnseen(c) || c.agents.some(a => isAgentUnseen(c, a))
545 const mark = glyphOf(state, isDone)
546 const isOn = c.sessionId === selectedId
547 return (
548 <Box key={`row-${c.sessionId}`} justifyContent="space-between" columnGap={1}>
549 <Box flexShrink={1}>
550 <Text color={P.accent}>{isOn ? '▌' : ' '}</Text>
551 <Text color={mark.color}>{`${mark.glyph} `}</Text>
552 <Button
553 plain
554 key={`session-${c.sessionId}`}
555 label={short(`${c.title || t.newSession}${c.sessionId === vm.mine ? t.here : ''}`, Math.max(12, listWidth - 20))}
556 dimColor={!isOn && state !== 'waiting' && state !== 'running' && !isDone}
557 onPress={() =>
558 // The title takes you there; this session, being here already, shows its details.
559 c.hostId && c.sessionId !== vm.mine
560 ? act.openSession(c.hostId)
561 : nav({ view: 'session', sessionId: c.sessionId })
562 }
563 />
564 </Box>
565 <Box flexShrink={0} columnGap={1}>
566 <Text color={state === 'waiting' ? P.waiting : state === 'running' ? P.running : isDone ? P.accent : P.faint}>
567 {wordOf(state, isDone)}
568 </Text>
569 <Button
570 plain
571 key={`more-${c.sessionId}`}
572 label="⋯"
573 dimColor={!isOn}
574 onPress={() => nav({ view: 'session', sessionId: c.sessionId })}
575 />
576 </Box>
577 </Box>
578 )
579 }
580
581 const projectRollup = (sessions: SessionCard[]): Mark => {
582 const states = sessions.map(c => ({ state: liveState(c), isDone: isUnseen(c) || c.agents.some(a => isAgentUnseen(c, a)) }))
583 const pick =
584 states.find(x => x.state === 'waiting') ??
585 states.find(x => x.state === 'running') ??
586 states.find(x => x.isDone) ??
587 states[0]
588 return pick ? markOf(pick.state, pick.isDone) : { glyph: '○', color: P.dim }
589 }
590
591 // Every subagent that is working, waiting, or freshly done, across the listed sessions.
592 const liveAgents = ordered.flatMap(c =>
593 c.agents
594 .filter(a => {
595 const st = agentState(a.status, a.waiting)
596 return st === 'running' || st === 'waiting' || isAgentUnseen(c, a)
597 })
598 .map(a => ({ c, a })),
599 )
600
601 const list = (
602 <Box key="list" flexDirection="column" width={listWidth} flexShrink={0}>
603 {vm.sessions.length === 0 ? (
604 <Text color={P.dim}>{t.noSessions}</Text>
605 ) : shown.length === 0 ? (
606 <Text color={P.dim}>{t.noMatch}</Text>
607 ) : (
608 projects.map((p, i) => {
609 const roll = projectRollup(p.sessions)
610 const branch = p.sessions.find(c => c.branch)?.branch
611 return (
612 <Box key={`p-${p.name}`} flexDirection="column" marginBottom={1}>
613 <Box>
614 {i < 9 ? (
615 <Button
616 plain
617 key={`project-${i + 1}`}
618 hotkey={String(i + 1)}
619 label={p.name}
620 onPress={() => {
621 const first = p.sessions[0]
622 if (first) nav({ view: 'session', sessionId: first.sessionId })
623 }}
624 />
625 ) : (
626 <Text bold color={P.text}>
627 {p.name}
628 </Text>
629 )}
630 <Text color={roll.color}>{` ${roll.glyph}`}</Text>
631 </Box>
632 {branch ? <Text color={P.accent}>{` ⎇ ${branch}`}</Text> : null}
633 {p.sessions.map(sessionRow)}
634 </Box>
635 )
636 })
637 )}
638 {liveAgents.length > 0 ? (
639 <Box key="agents" flexDirection="column">
640 <Text color={P.faint}>{t.agentsHeader}</Text>
641 {liveAgents.slice(0, 8).map(({ c, a }) => {
642 const st = agentState(a.status, a.waiting)
643 const isDone = isAgentUnseen(c, a)
644 const mark = glyphOf(st, isDone)
645 return (
646 <Box key={`la-${c.sessionId}-${a.id}`} justifyContent="space-between" columnGap={1}>
647 <Box flexShrink={1}>
648 <Text color={mark.color}>{` ${mark.glyph} `}</Text>
649 <Button
650 plain
651 key={`row-agent-${c.sessionId}-${a.id}`}
652 label={short(a.type, Math.max(8, listWidth - 16))}
653 onPress={() => nav({ view: 'agent', sessionId: c.sessionId, agentId: a.id })}
654 />
655 </Box>
656 <Text color={st === 'waiting' ? P.waiting : isDone ? P.accent : P.faint}>{wordOf(st, isDone)}</Text>
657 </Box>
658 )
659 })}
660 {liveAgents.length > 8 ? <Text color={P.faint}>{` +${liveAgents.length - 8}`}</Text> : null}
661 </Box>
662 ) : null}
663 </Box>
664 )
665
666 // ── the selected session, or agent ────────────────────────────────────
667 const routed = selectedId ? vm.sessions.find(c => c.sessionId === selectedId) : undefined
668 const routedAgent = route.view === 'agent' && routed ? routed.agents.find(a => a.id === route.agentId) : undefined
669 const detail =
670 routed && routedAgent ? agentPage(routed, routedAgent) : routed ? sessionPage(routed) : <Text color={P.faint}>{t.pickOne}</Text>
671
672 // ── footer: keys, settings, counts ────────────────────────────────────
673 const footer = (
674 <Box key="footer" flexDirection="column" marginTop={1}>
675 {rule()}
676 <Box justifyContent="space-between" flexWrap="wrap" columnGap={2}>
677 <Box columnGap={2} flexWrap="wrap">
678 <Button plain key="next" hotkey="j" label="↓" dimColor onPress={() => step(1)} />
679 <Button plain key="previous" hotkey="k" label="↑" dimColor onPress={() => step(-1)} />
680 {routed?.hostId && routed.sessionId !== vm.mine ? (
681 <Button
682 plain
683 key="open-selected"
684 hotkey="o"
685 label={t.open}
686 dimColor
687 onPress={() => routed.hostId && act.openSession(routed.hostId)}
688 />
689 ) : null}
690 <Button plain key="refresh" hotkey="r" label="⟳" dimColor onPress={act.refresh} />
691 <Button plain key="set-language" hotkey="l" label={language(t)} dimColor onPress={act.toggleLanguage} />
692 <Box>
693 <Text color={vm.notify ? P.running : P.faint}>{vm.notify ? '● ' : '○ '}</Text>
694 <Button
695 plain
696 key="set-notify"
697 hotkey="n"
698 label={vm.notify ? t.notifyOn : t.notifyOff}
699 dimColor={!vm.notify}
700 onPress={act.toggleNotify}
701 />
702 </Box>
703 </Box>
704 <Text>
705 <Text color={P.running}>{`● ${loopsRunning} `}</Text>
706 <Text color={P.dim}>{t.filterName.running.toLowerCase()}</Text>
707 <Text color={P.dim}>{' · '}</Text>
708 <Text color={P.waiting}>{`${loopsWaiting} `}</Text>
709 <Text color={P.dim}>{t.filterName.waiting.toLowerCase()}</Text>
710 <Text color={P.faint}>{` v${vm.version}`}</Text>
711 </Text>
712 </Box>
713 </Box>
714 )
715
716 return (
717 <Box flexDirection="column" width={vm.width}>
718 {header}
719 {isWide ? (
720 <Box key="body" columnGap={3} marginTop={1}>
721 {list}
722 <Box key="detail" flexDirection="column" width={main}>
723 {detail}
724 </Box>
725 </Box>
726 ) : (
727 <Box key="body" flexDirection="column" marginTop={1}>
728 {route.view === 'list' || !routed ? list : detail}
729 </Box>
730 )}
731 {footer}
732 </Box>
733 )
734}
735
736/** The board as plain text: `/agent-watch text`, and wherever no pane can be drawn. */
737export function drawText(vm: Pick<ViewModel, 'now' | 'sessions' | 'mine' | 't'>): string {
738 const { t, now } = vm
739 const counts: Record<LoopState, number> = { running: 0, waiting: 0, idle: 0, ended: 0 }
740 for (const c of vm.sessions) counts[liveState(c)] += 1
741 const mark: Record<LoopState, string> = { running: '●', waiting: '◆', idle: '○', ended: '×' }
742 const lines = [t.textHeader(counts.running, counts.waiting, counts.idle), '']
743 if (vm.sessions.length === 0) lines.push(t.noSessions)
744 for (const c of vm.sessions) {
745 const state = liveState(c)
746 const when = c.state === 'ended' ? t.endedAgo(clock(now - c.updatedAt)) : t.since(clock(now - c.since))
747 const where = c.cwd.replace(/\\/g, '/')
748 lines.push(`${mark[state]} ${c.title || t.newSession}${c.sessionId === vm.mine ? t.here : ''} — ${where} ${t.badge[state]} · ${when}`)
749 if (c.state === 'waiting') lines.push(` ${t.inputRequired}${c.waiting ? `: ${c.waiting}` : ''}`)
750 else if (c.state === 'running' && c.tool) {
751 const took = c.toolSince === undefined ? '' : ` · ${clock(now - c.toolSince)}`
752 lines.push(` ▸ ${c.tool}${took}`)
753 }
754 c.agents.forEach((a, i) => {
755 const s = agentState(a.status, a.waiting)
756 const branch = i === c.agents.length - 1 ? '└' : '├'
757 const elapsed = a.startedAt === undefined ? '' : ` · ${clock((s === 'ended' ? (a.endedAt ?? now) : now) - a.startedAt)}`
758 const activity = activityOf(a, now, t).text
759 lines.push(` ${branch} ${mark[s]} ${a.type} · ${a.label} ${t.badge[s]}${elapsed}${activity ? ` ${activity}` : ''}`)
760 })
761 }
762 return lines.join('\n')
763}
764types/index.d.ts 120 lines1/** Where a loop stands, as the panel shows it. */
2export type LoopState = 'running' | 'waiting' | 'idle' | 'ended'
3
4/** Which sessions the pane lists. */
5export type Filter = 'all' | LoopState
6
7/** Which page of the pane is shown. */
8export type Route =
9 | { view: 'list' }
10 | { view: 'session'; sessionId: string }
11 | { view: 'agent'; sessionId: string; agentId: string }
12
13/** One subagent or teammate of a session. */
14export type AgentCard = {
15 id: string
16 label: string
17 type: string
18 status: string
19 tool?: string
20 waiting?: string
21 parentId?: string
22 /** When it was spawned, when known. */
23 startedAt?: number
24 /** When it last wrote, for one that ended. */
25 endedAt?: number
26 /** When its transcript last changed. */
27 lastActivity?: number
28 /** When the tool call it is in started. */
29 toolSince?: number
30 /** Its tokens: generated (when the whole transcript was read) and its context's size. */
31 tokens?: Tokens
32}
33
34/** What a loop's requests cost: `out` generated in all, `ctx` the last request's input. */
35export type Tokens = { out?: number; ctx?: number }
36
37/** What one session writes to the shared folder, and the panel reads back. */
38export type SessionCard = {
39 sessionId: string
40 /** The desktop app's id of the session (`local_…`), which a deep link opens. */
41 hostId?: string
42 /** The git branch its folder is on, when it is in a repository. */
43 branch?: string
44 cwd: string
45 title: string
46 state: LoopState
47 waiting?: string
48 tool?: string
49 /** When the main loop's tool call started, where the session reports it. */
50 toolSince?: number
51 since: number
52 startedAt?: number
53 updatedAt: number
54 agents: AgentCard[]
55 /** Whether the session that wrote this report sends system notifications. */
56 notify?: boolean
57}
58
59/** A subagent's transcript, read when its page opens. */
60export type AgentDetail = {
61 agentId: string
62 isLoading: boolean
63 /** Its task as it was given, cut short. */
64 prompt?: string
65 /** Its last tool calls, newest last. */
66 tools: { name: string; detail: string; at?: string }[]
67 /** Its last words. */
68 answer?: string
69 error?: string
70}
71
72/** A session's conversation, read from the end of its transcript. */
73export type SessionConvo = {
74 sessionId: string
75 isLoading: boolean
76 /** The person's last message, and when. */
77 prompt?: string
78 promptAt?: string
79 /** Claude's last words, and when. */
80 answer?: string
81 answerAt?: string
82 /** Its last tool calls, newest last. */
83 tools: { name: string; detail: string; at?: string }[]
84 /** The context's size at its last request, and the model that answered it. */
85 ctx?: number
86 model?: string
87 error?: string
88}
89
90/** This session's own loop, kept across reloads. */
91export type SelfStatus = {
92 title: string
93 state: LoopState
94 waiting?: string
95 tool?: string
96 toolSince?: number
97 since: number
98 agentTools: Record<string, string>
99 agentWaiting: Record<string, string>
100}
101
102/** What the panel draws: every live session, read on the last tick. */
103export type Board = { now: number; sessions: SessionCard[] }
104
105declare module 'claude-code' {
106 interface PluginState {
107 'agent-watch': {
108 self: SelfStatus
109 board: Board
110 filter: Filter
111 collapsed: string[]
112 route: Route
113 detail: AgentDetail | null
114 convo: SessionConvo | null
115 /** When each session was last looked at, to tell a finished one you have not seen. */
116 seen: Record<string, number>
117 }
118 }
119}
120