SLOPSHOPPER

agent-watch

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

newpaneguardcommandtoaststatus
★ 1v0.3.1MITupdated 2026-10-07TheoGegout/claude-agent-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-watch
│ ┃ Agent Watch ✕ › fix the failing auth test and add an audit log call │ ┃ ◉ AGENT WATCH 0 session ◆ 0 blocked ● 0 w │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No sessions found. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ──────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ j: ↓ k: ↑ r: ⟳ l: EN ● n: 🔔 Notificatio ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agent-watch │ ⎿ agent-watch: Agent Watch opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent Watch
◉ AGENT WATCH 0 session ◆ 0 blocked ● 0 working ○ 0 idle ──────────────────────────────────────────────────────── No sessions found. ──────────────────────────────────────────────────────── j: ↓ k: ↑ r: ⟳ l: EN ● n: 🔔 Notifications ON ● 0 runni
README

Agent Watch

License: MIT Claude Code 2.1.287+

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

Install

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.

What you see

◉ 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.

MarkStateMeans
✓donefinished, or turned idle, since you last opened it; opening it clears the mark
● amber, WAITINGwaiting for youa permission dialog, a question (AskUserQuestion) or a plan to approve is open
● green, RUNNINGrunningthe session or a subagent is working; the tool in use when known
● grey, IDLEidlethe turn is over, the session waits for your next message
● red, ENDEDendedthe 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.

Use

CommandDoes
/agent-watchopen the pane
/agent-watch textprint 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:

KeyDoes
click a session / an agentopen its page
bback
j / knext / previous session
oopen the selected session in the app
click a count in the headershow only that state; again for all
rrefresh now
lswitch language (English / French)
nnotifications on / off
sstatus line on / off
escclose

The pane refreshes every second.

Where the data comes from

The mod only needs to be installed; the other sessions do not need it.

It readsFor
~/.claude/sessions/*.jsonClaude 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/*.jsonwhat 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:

  • Open ↗ hands the app's claude://code/continue?session=… link to the system (rundll32 url.dll,FileProtocolHandler on Windows, open on macOS, xdg-open on Linux).
  • A session's or a subagent's page, for a transcript over 4 MiB (more than one read of a mod holds), reads its last megabyte with scripts/tail.ps1, a seek to the end of the file.
  • A system notification when another session starts waiting: 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 Watch and agent-flow

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.

Configure

In /config, or under pluginConfigs["agent-watch"].options in settings.json:

OptionDefaultMeaning
languageenen or fr
notifyWaitingtruenotify when another session starts waiting for you
openOnStarttrueopen the pane by itself when a session starts
statusLinetrueshow the running / waiting count in the status line

Troubleshooting

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.

Develop

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.

License

MIT

Source 4 files
hooks/register.tsx 959 lines
1import { 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}
959
hooks/strings.ts 283 lines
1import 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}
283
hooks/view.tsx 764 lines
1import 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}
764
types/index.d.ts 120 lines
1/** 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