SLOPSHOPPER

mission-pane

A side pane (/ops) with this session's subagents and background jobs, live, and a sound and a toast as each one ends.

newpaneguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-10simplybychris/claude-code-mods/mission-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-pane
│ ┃ Mission ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing yet. Subagents and background jobs │ ┃ of this session show up here, live. ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /ops │ ⎿ mission-pane: Mission pane open: subagents and background jobs, │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mission
Nothing yet. Subagents and background jobs of this session show up here, live.
README

Panel agentów (mission-pane)

Boczny panel z subagentami i zadaniami w tle bieżącej sesji, na żywo: status, czas, liczba wywołań narzędzi, zajęty kontekst i ostatnia czynność. Gdy coś się skończy, pojawia się powiadomienie i krótki dźwięk.

Jak używać

  • /ops otwiera i zamyka panel.
  • Klawisze od 1 do 9 otwierają szczegóły wiersza: u agenta jego zadanie, oś czasu wywołań narzędzi z wynikami, ostatnie wiadomości i raport, a u zadania w tle polecenie i końcówkę jego wyjścia.
  • W szczegółach: b wraca do listy, p i n przechodzą do poprzedniego i następnego, f rozwija cały prompt.
  • c chowa zakończone wiersze.

Dobrze wiedzieć

  • Mod niczego nie wysyła i nie woła modelu. Czyta tylko to, co Claude Code już ma.
  • Dźwięk (sounds/done.wav) gra przez API modów. W terminalu na macOS działa od razu. W terminalu na Linuksie i Windows Claude Code nie ma odtwarzacza, więc zostaje samo powiadomienie.

Wymaga Claude Code 2.1.287+ w terminalu albo 2.1.286+ w aplikacji Desktop. Brak zależności.

Source 2 files
hooks/register.tsx 555 lines
1// mission-pane: a side pane (/ops) with this session's subagents and background jobs,
2// live. The list shows status, time, tool calls, context and last action; a row opens
3// its details: an agent's prompt, timeline of tool calls with results, messages and
4// report, or a job's command and the tail of its output. A sound and a toast as one ends.
5// It reads what Claude Code already holds: no model calls, no network.
6// Idea from agent-radar in hamzafer/claude-code-mods (MIT), grown into a pane.
7import type { AgentInfo, EngineInterface, Register, Timer } from 'claude-code'
8
9import { clip, cut, describeCall, elapsed, firstLine, isDone, isFailure, offset, oneLine, outputPath, parseNotification, short, tail } from './ops'
10import type { AgentStat, Job, Step } from './ops'
11
12const PANE = 'mission-pane'
13const TICK_MS = 1_000
14const KEEP_DONE = 10
15const MAX_STEPS = 300
16const PROMPT_LINES = 10
17const SOUND = 'sounds/done.wav'
18const SOUND_GAP_MS = 3_000
19const COLOR = { run: '#3fb950', wait: '#d29922', done: '#8b949e', fail: '#f85149', kind: '#58a6ff', action: '#d2a8ff', head: '#e6edf3' }
20
21type Row = {
22  key: string
23  id: string
24  isJob: boolean
25  depth: number
26  state: 'run' | 'wait' | 'done' | 'fail'
27  kind: string
28  label: string
29  time: string
30  tools?: number
31  ctx?: number
32  note?: string // the last action while running, the status once ended
33  endedAt: number
34}
35
36const isLive = (r: Row) => r.state === 'run' || r.state === 'wait'
37
38type View = { kind: 'list' } | { kind: 'agent'; id: string } | { kind: 'job'; id: string }
39
40// The module's state: a hot reload starts it over.
41const stats = new Map<string, AgentStat>() // per subagent, from its own loop's events
42const steps = new Map<string, Step[]>() // per subagent, its tool calls in order
43const reports = new Map<string, string>() // per subagent, its final answer
44const prompts = new Map<string, string>() // per subagent, the task it was given
45const promptsByLabel = new Map<string, string>() // Agent call description → prompt, until the id is known
46const jobs = new Map<string, Job>() // background shells, monitors, workflows
47let view: View = { kind: 'list' }
48let isPromptOpen = false
49let clearedAt = 0
50let hinted = false
51let lastSoundAt = 0
52let timer: Timer | undefined
53
54function stat(id: string, now: number): AgentStat {
55  const known = stats.get(id)
56  if (known) return known
57  const fresh: AgentStat = { startedAt: now, tools: 0 }
58  stats.set(id, fresh)
59  return fresh
60}
61
62function runningCount() {
63  return [...jobs.values()].filter(j => j.endedAt === undefined).length + [...stats.values()].filter(s => s.endedAt === undefined).length
64}
65
66// A job or agent ended: a toast, a sound (at most one every few seconds), a redraw.
67async function finished($: EngineInterface, label: string, ms: number, status: string) {
68  const now = await $.clock.now()
69  const failed = isFailure(status)
70  $.ui.toast(`${failed ? '✗' : '✓'} ${label} · ${elapsed(ms)}${failed ? ` · ${status}` : ''}`)
71  if (now - lastSoundAt >= SOUND_GAP_MS) {
72    lastSoundAt = now
73    void $.audio.play({ asset: SOUND }).catch(() => {})
74  }
75  $.ui.invalidate('ui.render')
76}
77
78async function startJob($: EngineInterface, job: Omit<Job, 'startedAt' | 'status'>) {
79  if (jobs.has(job.id)) return
80  jobs.set(job.id, { ...job, startedAt: await $.clock.now(), status: 'running' })
81  await hint($)
82  $.ui.invalidate('ui.render')
83}
84
85async function endJob($: EngineInterface, id: string, status: string) {
86  const job = jobs.get(id)
87  if (!job || job.endedAt !== undefined) return
88  const now = await $.clock.now()
89  job.endedAt = now
90  job.status = status
91  await finished($, `${job.kind} · ${clip(job.label, 40)}`, now - job.startedAt, status)
92}
93
94// With the pane closed and two or more things running, say so once a turn.
95async function hint($: EngineInterface) {
96  if (hinted || runningCount() < 2) return
97  if ((await $.ui.panes().catch(() => [])).some(p => p.id === PANE)) return
98  hinted = true
99  $.ui.toast(`${runningCount()} running · /ops`)
100}
101
102export const register: Register = on => {
103  on('session.start', async ($, e, next) => {
104    const result = await next(e)
105    await $.command.register({ name: 'ops', description: 'Open or close the pane of subagents and background jobs' }).catch(() => {})
106    timer?.cancel()
107    // The clocks move once a second, only while something runs and the pane is open.
108    timer = $.clock.every(TICK_MS, () => {
109      void (async () => {
110        if (runningCount() === 0) return
111        if (!(await $.ui.panes()).some(p => p.id === PANE)) return
112        $.ui.invalidate('ui.render')
113      })().catch(() => {})
114    })
115    return result
116  })
117
118  on('turn.start', async ($, e, next) => {
119    hinted = false
120    return next(e)
121  })
122
123  on('tool.call', async ($, e, next) => {
124    const tool = String(e.tool)
125    const input = e as unknown as Record<string, unknown>
126    const text = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
127    const summary = describeCall(tool, input)
128    let step: Step | undefined
129    if (e.agentId) {
130      const now = await $.clock.now()
131      const s = stat(e.agentId, now)
132      s.tools += 1
133      s.last = summary
134      step = { id: e.tool_use_id, at: now, summary }
135      steps.set(e.agentId, [...(steps.get(e.agentId) ?? []), step].slice(-MAX_STEPS))
136    }
137    if (tool === 'Agent') promptsByLabel.set(text('description'), text('prompt'))
138
139    const result = await next(e)
140    const failed = result.deny !== undefined || result.isError === true
141    if (step) {
142      step.endedAt = await $.clock.now()
143      step.ok = !failed
144      step.result = firstLine(result.deny ?? result.text)
145    }
146    const out = (result as { result?: unknown }).result
147    if (failed || !out || typeof out !== 'object') return result
148    const o = out as Record<string, unknown>
149    if (tool === 'Bash' && typeof o.backgroundTaskId === 'string') {
150      await startJob($, {
151        id: o.backgroundTaskId,
152        kind: 'shell',
153        label: clip(text('description') || oneLine(text('command')) || 'shell'),
154        command: text('command'),
155        outputPath: outputPath(result.text),
156      })
157    } else if (tool === 'Monitor' && typeof o.taskId === 'string') {
158      await startJob($, { id: o.taskId, kind: 'monitor', label: clip(text('description') || 'monitor'), command: text('command') })
159    } else if (tool === 'Workflow' && typeof o.taskId === 'string') {
160      const name = typeof o.workflowName === 'string' ? o.workflowName : text('name') || text('title') || 'workflow'
161      await startJob($, { id: o.taskId, kind: 'workflow', label: clip(name) })
162    } else if (tool === 'Agent' && typeof o.agentId === 'string') {
163      prompts.set(o.agentId, text('prompt'))
164    } else if (tool === 'TaskStop') {
165      const id = text('task_id') || text('shell_id')
166      if (id) await endJob($, id, 'killed')
167    }
168    return result
169  })
170
171  // A subagent's own requests: when it started, and how much context it carries.
172  on('turn.step', async function* ($, e, next) {
173    if (e.agentId) {
174      const s = stat(e.agentId, await $.clock.now())
175      if (s.endedAt !== undefined || s.waiting) {
176        s.endedAt = undefined // resumed: a stop was a pause, not its end
177        s.waiting = false
178        $.ui.invalidate('ui.render')
179      }
180    }
181    const result = yield* next(e)
182    if (e.agentId && result.usage) {
183      const u = result.usage
184      stat(e.agentId, await $.clock.now()).ctx = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
185      await hint($)
186    }
187    return result
188  })
189
190  // A subagent's loop ends with its own turn.complete, carrying its report.
191  on('turn.complete', async ($, e, next) => {
192    const result = await next(e)
193    if (!e.agentId) return result
194    if (e.answer) reports.set(e.agentId, e.answer)
195    const now = await $.clock.now()
196    const s = stat(e.agentId, now)
197    if (s.endedAt !== undefined) return result
198    const list = await $.agent.list().catch(() => [] as readonly AgentInfo[])
199    // Stopped with its own subagents still running: it waits for them, it is not done.
200    const live = list.filter(c => c.parentId === e.agentId && stats.get(c.id)?.endedAt === undefined && !isDone(c.status)).length
201    if (live > 0) {
202      s.waiting = true
203      s.last = `waiting for ${live} subagent${live === 1 ? '' : 's'}`
204      $.ui.invalidate('ui.render')
205      return result
206    }
207    s.endedAt = now
208    s.waiting = false
209    const info = list.find(a => a.id === e.agentId)
210    const status = e.isAborted ? 'killed' : e.reason === 'error' ? 'failed' : 'completed'
211    await finished($, `${info?.type ?? 'agent'} · ${clip(info?.description ?? e.agentId, 40)}`, now - s.startedAt, status)
212    return result
213  })
214
215  // A background job's end reaches the session as a task notification.
216  on('prompt.submit', async ($, e, next) => {
217    if (e.origin.kind === 'task-notification') {
218      const n = parseNotification(e.text)
219      if (n) await endJob($, n.id, n.status)
220    }
221    return next(e)
222  })
223
224  // At each stop Claude Code lists what is still in flight: pick up jobs started
225  // elsewhere (a command the person backgrounded) and close the ones that are gone.
226  on('classic.Stop', async ($, e, next) => {
227    const result = await next(e)
228    const tasks = e.background_tasks ?? []
229    const live = new Set(tasks.map(t => t.id))
230    for (const t of tasks) {
231      if (t.type === 'subagent' || jobs.has(t.id)) continue
232      await startJob($, { id: t.id, kind: t.type, label: clip(oneLine(t.description || t.command || t.name || t.type)), command: t.command })
233    }
234    for (const j of [...jobs.values()]) if (j.endedAt === undefined && !live.has(j.id)) await endJob($, j.id, 'completed')
235    return result
236  })
237
238  on('command.run', { command: 'ops' }, async $ => {
239    const isOpen = (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
240    if (isOpen) {
241      await $.ui.close({ id: PANE })
242      return { text: 'Mission pane closed. /ops opens it again.' }
243    }
244    view = { kind: 'list' }
245    await $.ui.open({ id: PANE, title: 'Mission', focus: true })
246    return { text: 'Mission pane open: subagents and background jobs, live. 1–9 opens a row.' }
247  })
248
249  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
250    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
251    const now = await $.clock.now()
252    const list = await $.agent.list().catch(() => [] as readonly AgentInfo[])
253    const agents = agentRows(list, now)
254    const background = jobRows(now)
255    const go = (next: View) => {
256      view = next
257      isPromptOpen = false
258      $.ui.invalidate('ui.render')
259    }
260
261    const glyph = (state: Row['state']) => (
262      <Text color={state === 'run' ? COLOR.run : state === 'wait' ? COLOR.wait : state === 'fail' ? COLOR.fail : COLOR.done}>
263        {state === 'run' ? '●' : state === 'wait' ? '◐' : state === 'fail' ? '✗' : '✓'}
264      </Text>
265    )
266
267    const heading = (title: string, extra?: string) => (
268      <Text key={`h-${title}`}>
269        <Text bold color={COLOR.head}>{title}</Text>
270        {extra ? <Text dimColor>{` · ${extra}`}</Text> : null}
271      </Text>
272    )
273
274    // The row a detail shows at its top, with ← back and ◀ ▶ through its siblings.
275    const detailBar = (rows: Row[], at: number, toView: (r: Row) => View) => {
276      const row = rows[at]
277      if (!row) return null
278      const older = rows[at - 1]
279      const newer = rows[at + 1]
280      return (
281        <Box key="bar" flexDirection="column" marginBottom={1}>
282          <Box flexDirection="row" gap={1}>
283            <Button key="back" label="← back" hotkey="b" onPress={() => go({ kind: 'list' })} />
284            <Button key="prev" label="◀" hotkey="p" dimColor={!older} onPress={() => older && go(toView(older))} />
285            <Button key="next" label="▶" hotkey="n" dimColor={!newer} onPress={() => newer && go(toView(newer))} />
286            <Text dimColor>{`${at + 1}/${rows.length}`}</Text>
287          </Box>
288          <Box flexDirection="row" gap={1}>
289            {glyph(row.state)}
290            <Text bold color={COLOR.kind}>{row.kind}</Text>
291            <Text bold>{row.label}</Text>
292          </Box>
293          <Text dimColor>
294            {[row.state === 'run' ? 'running' : row.state === 'wait' ? 'waiting' : row.note, row.time, row.tools !== undefined ? `${row.tools} tools` : '', row.ctx !== undefined ? `${short(row.ctx)} ctx` : '']
295              .filter(Boolean)
296              .join(' · ')}
297          </Text>
298        </Box>
299      )
300    }
301
302    // An agent's details: the task it got, its tool calls with results, its words, its report.
303    if (view.kind === 'agent') {
304      const id = view.id
305      const at = agents.findIndex(r => r.id === id)
306      if (at < 0) view = { kind: 'list' }
307      else {
308        const found = await $.session.messages({ agentId: id }).catch(() => [])
309        const messages = Array.isArray(found) ? found : []
310        const firstUser = messages.find(m => m.role === 'user' && m.text.trim() !== '')
311        const info = list.find(a => a.id === id)
312        const prompt = prompts.get(id) || (info ? promptsByLabel.get(info.description) : undefined) || firstUser?.text || ''
313        const promptLines = prompt.split('\n')
314        const shownPrompt = isPromptOpen ? prompt : promptLines.slice(0, PROMPT_LINES).join('\n')
315        const said = messages.filter(m => m.role === 'assistant' && m.text.trim() !== '').slice(-5)
316        const report = reports.get(id)
317        const start = stats.get(id)?.startedAt ?? 0
318        const timeline = steps.get(id) ?? []
319        return (
320          <Box flexDirection="column">
321            {detailBar(agents, at, r => ({ kind: 'agent', id: r.id }))}
322            <Box key="prompt" flexDirection="column" marginBottom={1}>
323              <Box flexDirection="row" gap={1}>
324                {heading('PROMPT')}
325                {promptLines.length > PROMPT_LINES && (
326                  <Button
327                    key="prompt-toggle"
328                    label={isPromptOpen ? 'less' : `all ${promptLines.length} lines`}
329                    hotkey="f"
330                    dimColor
331                    onPress={() => {
332                      isPromptOpen = !isPromptOpen
333                      $.ui.invalidate('ui.render')
334                    }}
335                  />
336                )}
337              </Box>
338              {prompt ? <Markdown key="prompt-text" dimColor text={cut(shownPrompt, 9_000)} /> : <Text dimColor>(not known yet)</Text>}
339            </Box>
340            <Box key="timeline" flexDirection="column" marginBottom={1}>
341              {heading('TIMELINE', `${timeline.length} tool calls`)}
342              {timeline.length === 0 && <Text dimColor>(no tool calls yet)</Text>}
343              {timeline.slice(-60).map(s => (
344                <Box key={`step-${s.id}`} flexDirection="row">
345                  <Box width={7}>
346                    <Text dimColor>{offset(s.at - start)}</Text>
347                  </Box>
348                  <Box width={2}>{glyph(s.endedAt === undefined ? 'run' : s.ok ? 'done' : 'fail')}</Box>
349                  <Box width={38}>
350                    <Text color={s.ok === false ? COLOR.fail : undefined} wrap="truncate">{s.summary}</Text>
351                  </Box>
352                  <Box flexGrow={1}>
353                    <Text dimColor={s.ok !== false} color={s.ok === false ? COLOR.fail : undefined} wrap="truncate">
354                      {s.endedAt === undefined ? '…' : s.result ? `→ ${s.result}` : ''}
355                    </Text>
356                  </Box>
357                </Box>
358              ))}
359            </Box>
360            {said.length > 0 && (
361              <Box key="messages" flexDirection="column" marginBottom={1}>
362                {heading('MESSAGES', `last ${said.length}`)}
363                {said.map((m, i) => (
364                  <Text key={`said-${i}`} wrap="wrap">
365                    <Text color={COLOR.action}>{'› '}</Text>
366                    <Text>{cut(m.text.trim(), 400)}</Text>
367                  </Text>
368                ))}
369              </Box>
370            )}
371            {report && (
372              <Box key="report" flexDirection="column">
373                {heading('REPORT')}
374                <Markdown key="report-text" text={cut(report, 9_000)} />
375              </Box>
376            )}
377          </Box>
378        )
379      }
380    }
381
382    // A job's details: its command and the last lines of its output, live.
383    if (view.kind === 'job') {
384      const id = view.id
385      const at = background.findIndex(r => r.id === id)
386      const job = jobs.get(id)
387      if (at < 0 || !job) view = { kind: 'list' }
388      else {
389        let lines: string[] | null = null
390        let why = 'No output file for this job.'
391        if (job.outputPath) {
392          const raw = await $.fs.read(job.outputPath).catch(() => null)
393          if (typeof raw === 'string') lines = tail(raw)
394          else why = 'The output file could not be read (gone, or over 4 MB).'
395        }
396        return (
397          <Box flexDirection="column">
398            {detailBar(background, at, r => ({ kind: 'job', id: r.id }))}
399            {job.command && (
400              <Box key="command" flexDirection="column" marginBottom={1}>
401                {heading('COMMAND')}
402                <Text dimColor wrap="wrap">{cut(job.command, 1_000)}</Text>
403              </Box>
404            )}
405            <Box key="output" flexDirection="column">
406              {heading('OUTPUT', lines ? `last ${lines.length} lines` : undefined)}
407              {lines === null && <Text dimColor>{why}</Text>}
408              {lines?.length === 0 && <Text dimColor>(nothing yet)</Text>}
409              {lines?.map((l, i) => (
410                <Text key={`out-${i}`} wrap="truncate">{l}</Text>
411              ))}
412            </Box>
413          </Box>
414        )
415      }
416    }
417
418    // The list: every agent and job, a row each, 1–9 opening one.
419    if (agents.length === 0 && background.length === 0) {
420      return <Text dimColor>Nothing yet. Subagents and background jobs of this session show up here, live.</Text>
421    }
422    const numbered = [...agents, ...background]
423    const section = (title: string, rows: Row[]) => {
424      const running = rows.filter(isLive).length
425      return (
426        <Box key={`section-${title}`} flexDirection="column" marginBottom={1}>
427          {heading(title, `${running} running · ${rows.length - running} done`)}
428          {rows.map(r => {
429            const n = numbered.indexOf(r) + 1
430            return (
431              <Box key={r.key} flexDirection="row" paddingLeft={r.depth * 2}>
432                <Box width={2}>{glyph(r.state)}</Box>
433                <Box width={10}>
434                  <Text color={COLOR.kind} wrap="truncate">{r.kind}</Text>
435                </Box>
436                <Box width={30}>
437                  <Text dimColor={!isLive(r)} wrap="truncate">{r.label}</Text>
438                </Box>
439                <Box width={9}>
440                  <Text dimColor>{r.time}</Text>
441                </Box>
442                <Box width={10}>
443                  <Text dimColor>{r.tools === undefined ? '' : `${r.tools} tools`}</Text>
444                </Box>
445                <Box width={9}>
446                  <Text dimColor>{r.ctx === undefined ? '' : `${short(r.ctx)} ctx`}</Text>
447                </Box>
448                <Box flexGrow={1}>
449                  <Text color={r.state === 'run' ? COLOR.action : r.state === 'wait' ? COLOR.wait : undefined} dimColor={!isLive(r)} wrap="truncate">
450                    {r.note ? (isLive(r) ? `→ ${r.note}` : r.note) : ''}
451                  </Text>
452                </Box>
453                <Button
454                  key={`open-${r.key}`}
455                  label="›"
456                  {...(n <= 9 ? { hotkey: String(n) } : {})}
457                  onPress={() => go(r.isJob ? { kind: 'job', id: r.id } : { kind: 'agent', id: r.id })}
458                />
459              </Box>
460            )
461          })}
462        </Box>
463      )
464    }
465    const hasDone = numbered.some(r => !isLive(r))
466    return (
467      <Box flexDirection="column">
468        {agents.length > 0 && section('AGENTS', agents)}
469        {background.length > 0 && section('BACKGROUND', background)}
470        {hasDone && (
471          <Button
472            key="clear"
473            label="clear done"
474            hotkey="c"
475            dimColor
476            onPress={() => {
477              void (async () => {
478                clearedAt = await $.clock.now()
479                $.ui.invalidate('ui.render')
480              })().catch(() => {})
481            }}
482          />
483        )}
484      </Box>
485    )
486  })
487}
488
489// Subagents as rows: running first, children under their parent, the last few done.
490function agentRows(list: readonly AgentInfo[], now: number): Row[] {
491  const toRow = (a: AgentInfo, depth: number): Row => {
492    const s = stats.get(a.id)
493    const ended = s?.endedAt ?? (isDone(a.status) ? now : undefined)
494    const state = ended === undefined ? (s?.waiting ? 'wait' : 'run') : isFailure(a.status) ? 'fail' : 'done'
495    return {
496      key: `agent-${a.id}`,
497      id: a.id,
498      isJob: false,
499      depth,
500      state,
501      kind: a.type,
502      label: oneLine(a.description || a.name || a.id),
503      time: s ? elapsed((s.endedAt ?? now) - s.startedAt) : '',
504      tools: s?.tools,
505      ctx: s?.ctx,
506      note: state === 'run' || state === 'wait' ? s?.last : a.status,
507      endedAt: ended ?? Infinity,
508    }
509  }
510  const ids = new Set(list.map(a => a.id))
511  const roots = list.filter(a => !a.parentId || !ids.has(a.parentId)).map(a => ({ a, row: toRow(a, 0) }))
512  const kept = keep(roots.map(r => r.row))
513  const out: Row[] = []
514  const walk = (a: AgentInfo, depth: number) => {
515    for (const child of list.filter(c => c.parentId === a.id)) {
516      const row = toRow(child, depth)
517      if (isLive(row) || row.endedAt > clearedAt) out.push(row)
518      walk(child, depth + 1)
519    }
520  }
521  for (const row of kept) {
522    const root = roots.find(r => r.row.key === row.key)
523    out.push(row)
524    if (root) walk(root.a, 1)
525  }
526  return out
527}
528
529function jobRows(now: number): Row[] {
530  return keep(
531    [...jobs.values()].map(j => ({
532      key: `job-${j.id}`,
533      id: j.id,
534      isJob: true,
535      depth: 0,
536      state: j.endedAt === undefined ? 'run' : isFailure(j.status) ? 'fail' : 'done',
537      kind: j.kind,
538      label: j.label,
539      time: elapsed((j.endedAt ?? now) - j.startedAt),
540      note: j.endedAt === undefined ? undefined : j.status,
541      endedAt: j.endedAt ?? Infinity,
542    })),
543  )
544}
545
546// Running rows first (oldest first), then the most recent done, at most KEEP_DONE, none cleared.
547function keep(rows: Row[]): Row[] {
548  const running = rows.filter(isLive)
549  const done = rows
550    .filter(r => !isLive(r) && r.endedAt > clearedAt)
551    .sort((a, b) => b.endedAt - a.endedAt)
552    .slice(0, KEEP_DONE)
553  return [...running, ...done]
554}
555
hooks/ops.ts 123 lines
1// mission-pane's text work: pure functions over plain values, no $ and no timers.
2
3export type JobKind = 'shell' | 'monitor' | 'workflow' | 'agent' | string
4export type Job = { id: string; kind: JobKind; label: string; startedAt: number; endedAt?: number; status: string; command?: string; outputPath?: string }
5// One tool call of a subagent, as its timeline shows it.
6export type Step = { id: string; at: number; summary: string; endedAt?: number; ok?: boolean; result?: string }
7// `waiting`: its loop stopped while subagents it started still run; it resumes when they report.
8export type AgentStat = { startedAt: number; endedAt?: number; waiting?: boolean; tools: number; last?: string; ctx?: number }
9
10const ACTION_CHARS = 48
11
12// What a tool call is doing, in a few words: `Bash npm test`, `Read register.tsx`.
13export function describeCall(tool: string, input: Record<string, unknown>): string {
14  const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
15  const base = (p: string) => p.slice(Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) + 1)
16  let detail = ''
17  switch (tool) {
18    case 'Bash':
19      detail = oneLine(str('description') || str('command'))
20      break
21    case 'Read':
22    case 'Write':
23    case 'Edit':
24    case 'NotebookEdit':
25      detail = base(str('file_path') || str('notebook_path'))
26      break
27    case 'Grep':
28    case 'Glob':
29      detail = `"${str('pattern')}"`
30      break
31    case 'WebFetch':
32      detail = host(str('url'))
33      break
34    case 'WebSearch':
35      detail = `"${str('query')}"`
36      break
37    case 'Agent':
38      detail = str('description')
39      break
40    case 'Skill':
41      detail = str('skill')
42      break
43    default:
44      if (tool.startsWith('mcp__')) return clip(tool.split('__').slice(1).join(' '))
45  }
46  return clip(detail ? `${tool} ${detail}` : tool)
47}
48
49// A task notification's id and status, read from its text:
50// `<task-id>b1</task-id> … <status>completed</status>`.
51export function parseNotification(text: string): { id: string; status: string } | null {
52  const id = text.match(/<task-id>\s*([^<\s]+)\s*<\/task-id>/)?.[1]
53  if (!id) return null
54  const status = text.match(/<status>\s*([^<]+?)\s*<\/status>/)?.[1] ?? 'completed'
55  return { id, status }
56}
57
58export function isDone(status: string) {
59  return !/^(running|pending|in_progress|async_launched|remote_launched)$/i.test(status)
60}
61
62export function isFailure(status: string) {
63  return /fail|error|kill|stop|abort/i.test(status)
64}
65
66export function elapsed(ms: number) {
67  const s = Math.max(0, Math.floor(ms / 1000))
68  if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
69  return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
70}
71
72export function short(tokens: number) {
73  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
74  if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`
75  return String(tokens)
76}
77
78export function oneLine(text: string) {
79  return text.replace(/\s+/g, ' ').trim()
80}
81
82// Shortens multi-line text (a prompt, a report) without joining its lines.
83export function cut(text: string, max: number) {
84  return text.length > max ? `${text.slice(0, max - 1)}…` : text
85}
86
87export function clip(text: string, max = ACTION_CHARS) {
88  const t = oneLine(text)
89  return t.length > max ? `${t.slice(0, max - 1)}…` : t
90}
91
92function host(url: string) {
93  return url.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
94}
95
96// The first line of a tool's result worth showing: `12 matches`, `exit 1: 2 failed`.
97export function firstLine(text: string | undefined, max = 60) {
98  const line = (text ?? '').split('\n').map(l => l.trim()).find(l => l !== '') ?? ''
99  return clip(line, max)
100}
101
102// Where a background shell writes its output, from what the Bash tool told the model.
103export function outputPath(text: string | undefined) {
104  return text?.match(/Output is being written to:\s*(\S+?\.output)\b/)?.[1]
105}
106
107// The last lines of a command's output as a terminal would show them: colors stripped,
108// and a line a progress bar rewrote with \r reduced to what it last said.
109export function tail(text: string, lines = 30) {
110  return text
111    .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
112    .split('\n')
113    .map(l => l.split('\r').filter(part => part !== '').pop() ?? '')
114    .filter((l, i, all) => l.trim() !== '' || i < all.length - 1)
115    .slice(-lines)
116}
117
118// Time since the agent started, as a timeline stamp: `0:05`, `12:40`.
119export function offset(ms: number) {
120  const s = Math.max(0, Math.floor(ms / 1000))
121  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
122}
123