SLOPSHOPPER

session-tasks

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

newpanebandcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-tasks
│ ┃ Background tasks ✕ › fix the failing auth test and add an audit log call │ ┃ 0 running in 0 of 0 sessions │ ┃ i: Show idle sessions (0) ⏺ Read(src/auth.ts) │ ┃ Reading the sessions… ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /alltasks │ ⎿ session-tasks: Background tasks panel opened. │ │ ○ Reading background tasks… Details ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
○ Reading background tasks… Details ⟨Claude Code's own drawing⟩
Pane · Background tasks
0 running in 0 of 0 sessions i: Show idle sessions (0) Reading the sessions…
README

session-tasks

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:

  • A line above the prompt, in every session from the start: ● 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.
  • Open: each desktop app session in the panel has an Open button (keys 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.

How it works

Every 10 s the mod:

  1. Reads the running sessions from ~/.claude/sessions/<pid>.json, the registry Claude Code keeps, and drops entries whose process is gone (tasklist on Windows, ps elsewhere).
  2. Finds each session's transcript, ~/.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.
  3. Works out which tasks are running:
  4. Started: a background Bash call (backgroundTaskId), a Monitor (taskId), a background Agent (async_launched) or a Workflow.
  5. Ended: a task notification with a status (completed, failed, killed, stopped), a Monitor-expired notice, a TaskStop, or a non-persistent Monitor's timeout passing.
  6. Gone with an earlier process: tasks started before the session's current process (a session that was closed and resumed) are left out.

The panel redraws only when something it shows has changed.

Limits

  • This is worked out from the transcript, not from Claude Code's own task list. A task that dies without a notification (its process was killed from outside) stays listed until its session restarts.
  • Background work started by a subagent is in the subagent's own transcript and is not listed.
  • The Mac/Linux reader (tail, head, awk) has not been tested yet; Windows has.
Source 3 files
hooks/register.tsx 273 lines
1import { 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}
273
hooks/tasks.ts 241 lines
1import 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}
241
types/index.d.ts 43 lines
1/** 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