A line above the prompt counting the background tasks running in every session, and a side panel listing them (shells, subagents, monitors, workflows) read…

A session can show as Completed while background tasks it started (shells, subagents, monitors, workflows) are still running. Claude Code shows background tasks only inside the session that owns them. This mod collects them from every running session, including sessions that don't run the mod:
● 3 background tasks running in 2 sessions · 1 here, or a dim ○ No background tasks running. Its Details button opens the panel. Another mod's band above the prompt (claude-dj's now playing) stays, below this line./alltasks: a side panel listing each session (by title) with its running tasks and when each started. Press i to also show idle sessions.1–9) that switches the app to it. It hands the session's claude://claude.ai/epitaxy/<id> link to the OS (rundll32 on Windows, open on macOS, xdg-open on Linux). Terminal sessions have no such link and no button.Every 10 s the mod:
~/.claude/sessions/<pid>.json, the registry Claude Code keeps, and drops entries whose process is gone (tasklist on Windows, ps elsewhere).~/.claude/projects/<project>/<session-id>.jsonl, and reads only what was added since the last read. A small reader (PowerShell on Windows, tail and awk elsewhere) skips to where the last read stopped and passes back only the lines about background work, so even a 35 MB transcript takes about a second to read the first time and almost nothing after that.backgroundTaskId), a Monitor (taskId), a background Agent (async_launched) or a Workflow.The panel redraws only when something it shows has changed.
tail, head, awk) has not been tested yet; Windows has.hooks/register.tsx 273 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SessionView } from '../types'
5import {
6 LINE_PATTERN, TICK_MS, age, applyLines, hotkey, label, newTrack, openerArgv, parsePids, parseRegistry,
7 runningTasks, sameView, sessionLink, sortSessions, bandText, counts, taskLabel,
8} from './tasks'
9import type { RegistryEntry, Track } from './tasks'
10
11const PANE = 'session-tasks'
12const TITLE = 'Background tasks'
13const sessions = atom({ plugin: 'session-tasks', key: 'sessions' } as const, [])
14const nowAtom = atom({ plugin: 'session-tasks', key: 'now' } as const, 0)
15const showIdle = atom({ plugin: 'session-tasks', key: 'showIdle' } as const, false)
16const errorAtom = atom({ plugin: 'session-tasks', key: 'error' } as const, null)
17
18/** Raw bytes read from a transcript per reader run; its matched lines stay well under 4 MiB. */
19const CHUNK = 16 * 1024 * 1024
20
21// Both readers read ST_N bytes from ST_OFFSET, print the lines matching ST_PAT
22// up to the last complete line, then `POS <offset after that line>`.
23const POWERSHELL_READER = [
24 "$ErrorActionPreference='Stop'",
25 '[Console]::OutputEncoding=[Text.Encoding]::UTF8',
26 "$fs=[IO.File]::Open($env:ST_FILE,'Open','Read','ReadWrite,Delete')",
27 'try { $o=[int64]$env:ST_OFFSET; $n=[int]$env:ST_N; $buf=New-Object byte[] $n; [void]$fs.Seek($o,0); $r=0; while ($r -lt $n) { $k=$fs.Read($buf,$r,$n-$r); if ($k -le 0) { break }; $r+=$k } } finally { $fs.Close() }',
28 '$end=-1; if ($r -gt 0) { $end=[Array]::LastIndexOf($buf,[byte]10,$r-1) }',
29 '$rx=[regex]$env:ST_PAT',
30 'if ($end -ge 0) { foreach ($l in [Text.Encoding]::UTF8.GetString($buf,0,$end+1).Split([char]10)) { if ($rx.IsMatch($l)) { [Console]::Out.WriteLine($l) } } }',
31 "[Console]::Out.WriteLine('POS '+($o+$end+1))",
32].join('; ')
33
34// Line ends are counted in bytes (LC_ALL=C); a last line with no newline is
35// left for the next read.
36const SH_READER =
37 'tail -c +$((ST_OFFSET+1)) "$ST_FILE" | head -c "$ST_N" | LC_ALL=C awk -v pat="$ST_PAT" -v o="$ST_OFFSET" -v n="$ST_N" ' +
38 `'{ if (have) print prev; have=0; start=p; p+=length($0)+1; if ($0 ~ pat) { prev=$0; have=1 } } ` +
39 `END { if (p > n) { p=start; have=0 } if (have) print prev; print "POS " (o+p) }'`
40
41type Followed = { file: string | null; offset: number; track: Track }
42
43// Per session: its transcript, how far it has been read and what it said.
44// Starts over on a reload, which rereads each transcript once.
45const followed = new Map<string, Followed>()
46let refreshing = false
47
48async function isWindows($: EngineInterface): Promise<boolean> {
49 return (await $.env.get('OS')) === 'Windows_NT'
50}
51
52async function configDir($: EngineInterface): Promise<string | null> {
53 const configured = await $.env.get('CLAUDE_CONFIG_DIR')
54 if (configured) return configured
55 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
56
57 return home ? `${home}/.claude` : null
58}
59
60/** The running sessions: the registry, less entries whose process is gone. */
61async function liveSessions($: EngineInterface, dir: string): Promise<RegistryEntry[]> {
62 const entries = await $.fs.list(`${dir}/sessions`).catch(() => [])
63 const found: RegistryEntry[] = []
64 for (const entry of entries) {
65 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
66 const text = await $.fs.read(`${dir}/sessions/${entry.name}`).catch(() => null)
67 const r = text === null ? null : parseRegistry(text)
68 if (r !== null) found.push(r)
69 }
70 const list = (await isWindows($))
71 ? await $.process.run(['tasklist', '/FO', 'CSV', '/NH']).catch(() => null)
72 : await $.process.run(['ps', '-A', '-o', 'pid=']).catch(() => null)
73 if (list === null || list.exitCode !== 0) return found
74 const pids = parsePids(list.stdout)
75
76 return found.filter(r => pids.has(r.pid))
77}
78
79/** The transcript of a session: `<config>/projects/<any project>/<id>.jsonl`. */
80async function findTranscript($: EngineInterface, dir: string, sessionId: string): Promise<string | null> {
81 const projects = await $.fs.list(`${dir}/projects`).catch(() => [])
82 for (const p of projects) {
83 if (p.kind !== 'dir') continue
84 const path = `${dir}/projects/${p.name}/${sessionId}.jsonl`
85 if (await $.fs.exists(path)) return path
86 }
87
88 return null
89}
90
91/** Reads what was appended to a session's transcript since the last read. */
92async function follow($: EngineInterface, f: Followed) {
93 if (f.file === null) return
94 const size = await $.fs.stat(f.file).then(s => s.size, () => -1)
95 if (size < 0) return
96 if (size < f.offset) {
97 f.offset = 0
98 f.track = newTrack()
99 }
100 const windows = await isWindows($)
101 while (f.offset < size) {
102 const env = {
103 ST_FILE: f.file,
104 ST_OFFSET: String(f.offset),
105 ST_N: String(Math.min(CHUNK, size - f.offset)),
106 ST_PAT: LINE_PATTERN,
107 }
108 const run = windows
109 ? await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', POWERSHELL_READER], { env, timeoutMs: 60_000 })
110 : await $.process.run(['sh', '-c', SH_READER], { env, timeoutMs: 60_000 })
111 const lines = run.stdout.split(/\r?\n/)
112 const pos = lines.map(l => /^POS (\d+)$/.exec(l)).find(m => m !== null)
113 if (run.exitCode !== 0 || pos == null) {
114 throw new Error(run.stderr.trim().split(/\r?\n/)[0] || 'the transcript reader failed')
115 }
116 applyLines(f.track, lines.filter(l => !l.startsWith('POS ')))
117 const next = Number(pos[1])
118 // Nothing complete to read (a line still being written): try again next tick.
119 if (next <= f.offset) break
120 f.offset = next
121 }
122}
123
124async function refresh($: EngineInterface) {
125 if (refreshing) return
126 refreshing = true
127 try {
128 const dir = await configDir($)
129 if (dir === null) throw new Error('no home folder (USERPROFILE, HOME or CLAUDE_CONFIG_DIR) is set')
130 const now = await $.clock.now()
131 const live = await liveSessions($, dir)
132
133 const views: SessionView[] = []
134 for (const r of live) {
135 let f = followed.get(r.sessionId)
136 if (f === undefined || f.file === null) {
137 f = { file: await findTranscript($, dir, r.sessionId), offset: 0, track: newTrack() }
138 followed.set(r.sessionId, f)
139 }
140 await follow($, f)
141 views.push({
142 sessionId: r.sessionId,
143 title: r.name ?? null,
144 cwd: r.cwd,
145 status: r.status ?? 'unknown',
146 tasks: runningTasks(f.track, r.startedAt, now),
147 hasTranscript: f.file !== null,
148 link: sessionLink(r),
149 })
150 }
151 for (const id of [...followed.keys()]) if (!live.some(r => r.sessionId === id)) followed.delete(id)
152
153 const sorted = sortSessions(views)
154 if (!sameView(await read($, sessions), sorted)) await update($, sessions, () => sorted)
155 const minute = Math.floor(now / 60_000) * 60_000
156 if ((await read($, nowAtom)) !== minute) await update($, nowAtom, () => minute)
157 if ((await read($, errorAtom)) !== null) await update($, errorAtom, () => null)
158 } catch (err) {
159 const message = err instanceof Error ? err.message : String(err)
160 if ((await read($, errorAtom)) !== message) await update($, errorAtom, () => message)
161 } finally {
162 refreshing = false
163 }
164}
165
166/** Shows a session in the desktop app by handing its link to the OS. */
167async function openSession($: EngineInterface, s: SessionView) {
168 if (s.link === null) return
169 const platform = (await isWindows($)) ? 'windows' : (await $.env.get('HOME'))?.startsWith('/Users/') ? 'mac' : 'linux'
170 const run = await $.process.run(openerArgv(platform, s.link)).catch((err: unknown) => err)
171 if (run instanceof Error || (typeof run === 'object' && run !== null && 'exitCode' in run && run.exitCode !== 0)) {
172 $.ui.toast(`Could not open ${label(s)}; pick it in the sidebar`)
173 }
174}
175
176async function openPane($: EngineInterface) {
177 void refresh($)
178 await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true })
179}
180
181export const register: Register = on => {
182 on('session.start', async ($, e, next) => {
183 await $.command.register({
184 name: 'alltasks',
185 description: "Show every session's running background tasks in a side panel",
186 })
187 $.clock.every(TICK_MS, () => void refresh($))
188 void refresh($)
189
190 return next(e)
191 })
192
193 on('command.run', { command: 'alltasks' }, async $ => {
194 await openPane($)
195
196 return { text: 'Background tasks panel opened.' }
197 })
198
199 // One line above the prompt in every session; Details opens the panel.
200 // Another plugin's band (the engine's next) stays, drawn below this line.
201 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
202 if (e.props.hasSurvey) return next(e)
203 const { Box, Text, Button } = $.ui.resolve(e)
204 const all = await read($, sessions)
205 const error = await read($, errorAtom)
206 const self = await $.session.id()
207 const busy = counts(all).tasks > 0
208 const below = await next(e)
209 const line = (
210 <Box key="session-tasks" flexDirection="row" gap={1}>
211 <Text color={busy ? 'cyan' : undefined} dimColor={!busy}>{busy ? '●' : '○'}</Text>
212 <Text dimColor={!busy}>{error !== null && all.length === 0 ? 'Background tasks: could not read the sessions' : bandText(all, self)}</Text>
213 <Button key="details" label="Details" plain onPress={() => openPane($)} />
214 </Box>
215 )
216 if (below == null) return line
217
218 return (
219 <Box flexDirection="column">
220 {line}
221 {below}
222 </Box>
223 )
224 })
225
226 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
227 const { Box, Text, Button } = $.ui.resolve(e)
228 const self = await $.session.id()
229 const now = await read($, nowAtom)
230 const idle = await read($, showIdle)
231 const error = await read($, errorAtom)
232 const all = await read($, sessions)
233 const busy = all.filter(s => s.tasks.length > 0)
234 const shown = idle ? all : busy
235 const running = busy.reduce((n, s) => n + s.tasks.length, 0)
236
237 return (
238 <Box flexDirection="column">
239 <Text bold>{`${running} running in ${busy.length} of ${all.length} sessions`}</Text>
240 <Button
241 key="idle"
242 label={idle ? 'Hide idle sessions' : `Show idle sessions (${all.length - busy.length})`}
243 hotkey="i"
244 plain
245 onPress={() => update($, showIdle, v => !v)}
246 />
247 {error !== null && <Text color="red">{`Could not read the sessions: ${error}`}</Text>}
248 {shown.length === 0 && error === null && (
249 <Text dimColor>{all.length === 0 ? 'Reading the sessions…' : 'No background tasks in any session.'}</Text>
250 )}
251 {shown.map((s, i) => (
252 <Box key={`s:${s.sessionId}`} flexDirection="column" marginTop={1}>
253 <Box>
254 <Text bold>{`${label(s)}${s.sessionId === self ? ' (this session)' : ''} `}</Text>
255 {s.link !== null && s.sessionId !== self && (
256 <Button key={`open:${s.sessionId}`} label="Open" hotkey={hotkey(i)} plain onPress={() => openSession($, s)} />
257 )}
258 </Box>
259 <Text dimColor>{`${s.status} · ${s.cwd}${s.hasTranscript ? '' : ' · no transcript found'}`}</Text>
260 {s.tasks.length === 0 && <Text dimColor> nothing in the background</Text>}
261 {s.tasks.map(t => (
262 <Text key={`t:${s.sessionId}:${t.id}`}>
263 {` ${taskLabel(t)} `}
264 <Text dimColor>{t.startedAt > 0 ? `started ${age(now - t.startedAt)}` : ''}</Text>
265 </Text>
266 ))}
267 </Box>
268 ))}
269 </Box>
270 )
271 })
272}
273hooks/tasks.ts 241 lines1import type { SessionView, Task } from '../types'
2
3/** How often the sessions and their transcripts are reread. */
4export const TICK_MS = 10_000
5
6/**
7 * The transcript lines worth handing back from the reader: the tool calls that
8 * start or stop background work, their results, and task notifications.
9 */
10export const LINE_PATTERN =
11 '"name":"(Bash|Monitor|Agent|Workflow|TaskStop)"|backgroundTaskId|"taskId"|async_launched|"task_id"|<task-notification>'
12
13/** One entry of `~/.claude/sessions/<pid>.json`, the running-session registry. */
14export type RegistryEntry = {
15 pid: number
16 sessionId: string
17 cwd: string
18 startedAt: number
19 name?: string
20 status?: string
21 kind?: string
22 /** The desktop app's id for the session; absent for a terminal session. */
23 hostSessionId?: string
24}
25
26/** The link that opens a desktop app session in the app, or null for a terminal session. */
27export function sessionLink(r: RegistryEntry): string | null {
28 return r.hostSessionId ? `claude://claude.ai/epitaxy/${encodeURIComponent(r.hostSessionId)}` : null
29}
30
31/** The command that hands a link to the app registered for its scheme. */
32export function openerArgv(platform: 'windows' | 'mac' | 'linux', link: string): string[] {
33 if (platform === 'windows') return ['rundll32.exe', 'url.dll,FileProtocolHandler', link]
34
35 return [platform === 'mac' ? 'open' : 'xdg-open', link]
36}
37
38/** One digit hotkey for each of the first nine rows. */
39export function hotkey(index: number): string | undefined {
40 return index < 9 ? String(index + 1) : undefined
41}
42
43export function parseRegistry(text: string): RegistryEntry | null {
44 try {
45 const r = JSON.parse(text) as RegistryEntry
46 if (typeof r?.sessionId !== 'string' || typeof r.pid !== 'number') return null
47
48 return r
49 } catch {
50 return null
51 }
52}
53
54/** The pids in `tasklist /FO CSV /NH` or `ps -A -o pid=` output. */
55export function parsePids(text: string): Set<number> {
56 const pids = new Set<number>()
57 for (const line of text.split(/\r?\n/)) {
58 const csv = /^"[^"]*","(\d+)"/.exec(line)
59 const bare = /^\s*(\d+)\s*$/.exec(line)
60 const pid = csv?.[1] ?? bare?.[1]
61 if (pid !== undefined) pids.add(Number(pid))
62 }
63
64 return pids
65}
66
67type Use = { name: string; input: Record<string, unknown> }
68
69/** What one session's transcript says about its background work so far. */
70export type Track = {
71 uses: Map<string, Use>
72 tasks: Map<string, Task>
73 ended: Set<string>
74}
75
76export function newTrack(): Track {
77 return { uses: new Map(), tasks: new Map(), ended: new Set() }
78}
79
80const BACKGROUND_TOOLS = new Set(['Bash', 'Monitor', 'Agent', 'Workflow', 'TaskStop'])
81
82const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
83
84function end(track: Track, id: string) {
85 track.tasks.delete(id)
86 track.ended.add(id)
87}
88
89/** Ends the tasks a `<task-notification>` reports finished, stopped or expired. */
90export function applyNotification(track: Track, text: string) {
91 if (!text.includes('<task-notification>')) return
92 const isOver = /<status>\w+<\/status>/.test(text) || text.includes('[Monitor expired')
93 if (!isOver) return
94 for (const m of text.matchAll(/<task-id>([^<]+)<\/task-id>/g)) end(track, m[1]!)
95}
96
97function startTask(track: Track, id: string, use: Use | undefined, result: Record<string, unknown>, at: number) {
98 if (track.ended.has(id) || track.tasks.has(id)) return
99 const input = use?.input ?? {}
100 const name = use?.name ?? (result.backgroundTaskId !== undefined ? 'Bash' : 'Task')
101 const type =
102 name === 'Bash' ? 'shell'
103 : name === 'Monitor' ? 'monitor'
104 : name === 'Agent' ? 'subagent'
105 : name === 'Workflow' ? 'workflow'
106 : 'task'
107 const timeout = typeof result.timeoutMs === 'number' ? result.timeoutMs : undefined
108 const task: Task = {
109 id,
110 type,
111 description: str(input.description) ?? str(result.description) ?? str(input.name) ?? '',
112 startedAt: at,
113 }
114 const command = str(input.command)
115 if (command !== undefined) task.command = command
116 const agentType = str(input.subagent_type)
117 if (agentType !== undefined) task.agentType = agentType
118 if (type === 'monitor' && timeout !== undefined && result.persistent !== true) task.endsBy = at + timeout
119 track.tasks.set(id, task)
120}
121
122/** Applies one transcript record (a parsed JSONL line). */
123export function applyRecord(track: Track, record: unknown) {
124 if (record === null || typeof record !== 'object') return
125 const o = record as Record<string, unknown>
126 const at = typeof o.timestamp === 'string' ? Date.parse(o.timestamp) || 0 : 0
127
128 if (o.type === 'queue-operation' && typeof o.content === 'string') applyNotification(track, o.content)
129
130 const message = o.message as { content?: unknown } | undefined
131 const content = message?.content
132 if (typeof content === 'string') applyNotification(track, content)
133 if (!Array.isArray(content)) return
134
135 for (const block of content as Record<string, unknown>[]) {
136 if (block?.type === 'text' && typeof block.text === 'string') applyNotification(track, block.text)
137 if (block?.type === 'tool_use' && typeof block.name === 'string' && BACKGROUND_TOOLS.has(block.name)) {
138 track.uses.set(String(block.id), { name: block.name, input: (block.input ?? {}) as Record<string, unknown> })
139 }
140 if (block?.type !== 'tool_result') continue
141 const use = track.uses.get(String(block.tool_use_id))
142 const result = o.toolUseResult
143 if (result === null || typeof result !== 'object') continue
144 const r = result as Record<string, unknown>
145 if (use?.name === 'TaskStop' || (use === undefined && str(r.task_id) !== undefined && str(r.message)?.includes('stopped'))) {
146 const id = str(r.task_id) ?? str(use?.input.task_id)
147 if (id !== undefined) end(track, id)
148 continue
149 }
150 const id =
151 str(r.backgroundTaskId)
152 ?? (use !== undefined && use.name !== 'Bash' ? str(r.taskId) : undefined)
153 ?? (r.status === 'async_launched' ? str(r.agentId) : undefined)
154 if (id !== undefined && (use !== undefined || str(r.backgroundTaskId) !== undefined)) startTask(track, id, use, r, at)
155 // The call is answered; only its task, if any, is still needed.
156 track.uses.delete(String(block.tool_use_id))
157 }
158}
159
160/** Applies the reader's matched lines; a line that is not JSON is skipped. */
161export function applyLines(track: Track, lines: readonly string[]) {
162 for (const line of lines) {
163 if (line.trim() === '') continue
164 try {
165 applyRecord(track, JSON.parse(line))
166 } catch {
167 // A line cut by the reader or not a record at all.
168 }
169 }
170}
171
172/**
173 * The tasks still running: started by the session's current process (a task
174 * does not outlive the process that started it) and not past a monitor's expiry.
175 */
176export function runningTasks(track: Track, processStartedAt: number, now: number): Task[] {
177 return [...track.tasks.values()]
178 .filter(t => t.startedAt === 0 || t.startedAt >= processStartedAt - 5_000)
179 .filter(t => t.endsBy === undefined || t.endsBy > now)
180 .sort((a, b) => a.startedAt - b.startedAt)
181}
182
183/** Busy sessions first (most tasks), then by title. */
184export function sortSessions(list: readonly SessionView[]): SessionView[] {
185 return [...list].sort((a, b) => b.tasks.length - a.tasks.length || label(a).localeCompare(label(b)))
186}
187
188export function counts(list: readonly SessionView[]): { tasks: number; sessions: number } {
189 const busy = list.filter(s => s.tasks.length > 0)
190
191 return { tasks: busy.reduce((n, s) => n + s.tasks.length, 0), sessions: busy.length }
192}
193
194/** The band's line: how many tasks run, in how many sessions, how many in this one. */
195export function bandText(list: readonly SessionView[], self: string | null): string {
196 if (list.length === 0) return 'Reading background tasks…'
197 const { tasks, sessions } = counts(list)
198 if (tasks === 0) return 'No background tasks running'
199 const here = list.find(s => s.sessionId === self)?.tasks.length ?? 0
200 const where = sessions === 1 && here > 0 ? 'in this session' : `in ${sessions} session${sessions === 1 ? '' : 's'}`
201
202 return `${tasks} background task${tasks === 1 ? '' : 's'} running ${where}${here > 0 && sessions > 1 ? ` · ${here} here` : ''}`
203}
204
205export function age(ms: number): string {
206 const s = Math.max(0, Math.round(ms / 1000))
207 if (s < 60) return 'just now'
208 const m = Math.round(s / 60)
209 if (m < 60) return `${m}m ago`
210 const h = Math.round(m / 60)
211
212 return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
213}
214
215function clip(text: string, max: number): string {
216 const one = text.replace(/\s+/g, ' ').trim()
217
218 return one.length > max ? `${one.slice(0, max - 1)}…` : one
219}
220
221/** One line for a task: its kind, then what it is. */
222export function taskLabel(t: Task, max = 70): string {
223 const what =
224 t.type === 'subagent' && t.agentType ? `${t.agentType}: ${t.description}`
225 : t.description || t.command || t.id
226
227 return `${t.type.padEnd(8)} ${clip(what, max)}`
228}
229
230export function label(s: SessionView): string {
231 if (s.title) return s.title
232 const parts = s.cwd.split(/[\\/]/).filter(Boolean)
233
234 return `${parts.at(-1) ?? s.cwd} (${s.sessionId.slice(0, 8)})`
235}
236
237/** Whether two reads differ in anything the panel shows. */
238export function sameView(a: readonly SessionView[], b: readonly SessionView[]): boolean {
239 return JSON.stringify(a) === JSON.stringify(b)
240}
241types/index.d.ts 43 lines1/** One background task, as read from a session's transcript. */
2export type Task = {
3 id: string
4 /** shell, monitor, subagent, workflow */
5 type: string
6 description: string
7 command?: string
8 agentType?: string
9 /** When it was started, ms since the epoch. */
10 startedAt: number
11 /** A monitor's expiry, when it has one. */
12 endsBy?: number
13}
14
15/** One running Claude Code session and its background tasks. */
16export type SessionView = {
17 sessionId: string
18 title: string | null
19 cwd: string
20 /** idle or busy, as the session reports it. */
21 status: string
22 tasks: Task[]
23 /** False when no transcript was found for the session. */
24 hasTranscript: boolean
25 /** Opens the session in the desktop app; null for a terminal session. */
26 link: string | null
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'session-tasks': {
32 /** Every running session, as last read. */
33 sessions: SessionView[]
34 /** Minute the panel's ages are measured against. */
35 now: number
36 /** Show sessions with no tasks too. */
37 showIdle: boolean
38 /** Why the last read failed, if it did. */
39 error: string | null
40 }
41 }
42}
43