SLOPSHOPPER

jobs

Dotfiles – (neo)vim, tmux and zsh configuration

newpanebandguardcommandtoast
v?no licenseupdated 2026-10-03rcliao/dotfiles/dot_claude/mods/jobs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jobs
│ ┃ Background jobs ✕ › fix the failing auth test and add an audit log call │ ┃ No background jobs. │ ┃ Started with run_in_background, a background ⏺ Read(src/auth.ts) │ ┃ agent, or /jobs watch <file>. ⎿ 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 │ │ › /jobs │ ⎿ jobs: No background jobs. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Background jobs
No background jobs. Started with run_in_background, a background agent, or /jobs watch <file>.
README

Eric's Dotfiles

Managed with chezmoi. Shell, terminals, editors, git, and the Claude Code setup I actually work in.

Set up a new machine

brew install chezmoi
chezmoi init --apply rcliao

That prompts for three things and writes them to ~/.config/chezmoi/chezmoi.toml, which stays local to the machine and is never committed:

PromptWhy it is per-machine
Full namegit author name
Git emailso a work laptop and a personal box can share this repo without cross-signing commits
Ghost hookssee Ghost below

Re-running chezmoi init later is non-interactive — it keeps whatever is already set. To change an answer, edit ~/.config/chezmoi/chezmoi.toml and chezmoi apply. For an unattended install, chezmoi init --apply --promptDefaults takes the personal identity with Ghost off.

The first apply runs brew bundle for everything below. A package failure warns rather than aborting, so the dotfiles land either way.

Day to day

chezmoi diff                 # what would change
chezmoi apply                # everything
chezmoi apply ~/.zshrc       # just one target
chezmoi add ~/.tmux.conf     # pull a local edit back into the repo
chezmoi update               # git pull + apply

This repo is edited from more than one machine, so read chezmoi diff before applying. Whichever machine committed last is not automatically the machine that is more correct.

What is in here

Shell — .zshrc (zsh, pure prompt, shared history across panes, cached compinit), .gitconfig (delta pager, zdiff3 conflicts, rerere, auto-set upstream on push), .config/git/ignore, .ssh/config (keychain-backed agent loading — this is what replaces the ssh-add -A that used to run on every shell start).

Terminals — ghostty.

Multiplexers — herdr (primary, keys mapped to match tmux muscle memory), tmux as the fallback. The old zellij config is kept under archive/, which is not deployed.

Editors — neovim (0.12+: one init.lua, plugins via the built-in vim.pack, LSP for Go, TypeScript, Rust, Python and Terraform from Brewfile-installed servers), emacs.

Runtimes — mise, activated at the end of .zshrc so it wins over the PATH exports above it. Homebrew still provides the global node, go, and python; mise only takes over inside a directory that pins a version in mise.toml or .tool-versions.

Window management — aerospace.

Claude Code — CLAUDE.md, settings.json, and the hook scripts under .claude/hooks. settings.json is a template: hooks belonging to tools that may not be installed (herdr, Zero) are only wired in when their script is actually present, so a machine never ends up pointing at a hook that does not exist.

Other — bat, yazi.

Ghost memory hooks (opt-in)

The ghost-* hooks drive the Ghost MCP memory server. A machine without Ghost has no use for them, so they are off by default: neither the scripts nor their settings.json entries are installed.

To turn them on, set ghost = true in ~/.config/chezmoi/chezmoi.toml and chezmoi apply. To turn them off again, flip it back and apply.

aerospace

Homebrew refuses casks from untrusted taps, and trusting one is a decision worth making deliberately, so the bundle does not install aerospace:

brew trust --cask nikitabobko/tap/aerospace   # this one cask, not the whole tap
brew install --cask nikitabobko/tap/aerospace

What is deliberately not tracked

Anything regenerated, tool-managed, or machine-specific — Claude Code session transcripts and caches, plugin directories, herdr's own integration hook, and ~/.config/chezmoi/chezmoi.toml itself. See .chezmoiignore, which lists each exclusion explicitly so chezmoi add ~/.claude can never sweep one in.

This repo is public, so nothing employer-specific goes in it.

Source 3 files
hooks/register.tsx 299 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Job } from '../types'
5import {
6  bandRows,
7  duration,
8  lastLineOf,
9  parseBackgroundStart,
10  parseProgress,
11  parseTaskEnds,
12  progressText,
13  pushHistory,
14  shortLabel,
15  statusLine,
16  trimJobs,
17  visibleLines,
18} from './progress.ts'
19import type { TaskEnd } from './progress.ts'
20
21const PANE = 'jobs'
22const TITLE = 'Background jobs'
23const POLL_MS = 5_000
24const TAIL_BYTES = '16384'
25
26const REPORT_RULE =
27  'When you start a long-running command with run_in_background and you control its output (a script or loop you write), have it print progress lines `::progress <done>/<total> <what>` (or `::progress <n>%`), and `::status <text>` on a phase change; the user sees them live in the jobs footer and pane.'
28
29const listAtom = atom({ plugin: 'jobs', key: 'list' } as const, [] as Job[])
30const lastPromptAtom = atom({ plugin: 'jobs', key: 'lastPromptAt' } as const, 0)
31
32const finish = (jobs: readonly Job[], ends: readonly TaskEnd[], now: number) => {
33  const done: Job[] = []
34  const list = jobs.map(job => {
35    const end = ends.find(x => x.id === job.id)
36    if (end === undefined || job.status !== 'running') return job
37    const next: Job = { ...job, status: end.status, endedAt: now }
38    if (end.exitCode !== undefined) next.exitCode = end.exitCode
39    done.push(next)
40    return next
41  })
42  return { list: trimJobs(list), done }
43}
44
45// The rows live in the band above the prompt (info, not a ⚠ pinned status); redraw it.
46async function refreshStatus($: EngineInterface): Promise<void> {
47  $.ui.invalidate('ui.render')
48}
49
50async function endJobs($: EngineInterface, ends: readonly TaskEnd[]): Promise<void> {
51  if (ends.length === 0) return
52  const now = await $.clock.now()
53  let done: Job[] = []
54  await update($, listAtom, prev => {
55    const out = finish(prev ?? [], ends, now)
56    done = out.done
57    return out.list
58  })
59  // Only what you'd otherwise miss gets a toast; a completed job is a dim band row.
60  for (const job of done) if (job.status !== 'completed') $.ui.toast(`${job.label}: ${progressText(job, now)}`)
61  await refreshStatus($)
62}
63
64async function addJob($: EngineInterface, job: Job): Promise<void> {
65  await update($, listAtom, prev => trimJobs([...(prev ?? []).filter(j => j.id !== job.id), job]))
66  await refreshStatus($)
67}
68
69// One pass over running jobs: tail logs that grew, ask the engine about agents.
70async function poll($: EngineInterface): Promise<void> {
71  const jobs = await read($, listAtom)
72  const running = jobs.filter(j => j.status === 'running')
73  // Still refresh when idle, so a finished job leaves the status line once it is no longer recent.
74  if (running.length === 0) return refreshStatus($)
75  const now = await $.clock.now()
76  const changes = new Map<string, Partial<Job>>()
77
78  for (const job of running) {
79    if (job.file === undefined) continue
80    try {
81      const stat = await $.fs.stat(job.file)
82      if (stat.size === job.size) continue
83      const tail = await $.process.run(['tail', '-c', TAIL_BYTES, job.file], { timeoutMs: 3_000 })
84      const lines = visibleLines(tail.stdout)
85      const progress = parseProgress(lines)
86      const change: Partial<Job> = { size: stat.size, changedAt: now, history: pushHistory(job.history, now, progress?.pct) }
87      const last = lastLineOf(lines)
88      if (last !== undefined) change.lastLine = last
89      if (progress !== undefined) change.progress = progress
90      changes.set(job.id, change)
91    } catch {
92      // A log that vanished or cannot be read leaves the job as it was.
93    }
94  }
95
96  const ends: TaskEnd[] = []
97  if (running.some(j => j.kind === 'agent')) {
98    const agents = await $.agent.list()
99    for (const job of running.filter(j => j.kind === 'agent')) {
100      const agent = agents.find(a => a.id === job.id)
101      if (agent === undefined || agent.status === 'running') continue
102      ends.push({ id: job.id, status: agent.status === 'completed' ? 'completed' : agent.status === 'killed' ? 'killed' : 'failed' })
103    }
104  }
105
106  if (changes.size > 0) {
107    await update($, listAtom, prev => (prev ?? []).map(j => ({ ...j, ...(changes.get(j.id) ?? {}) })))
108  }
109  await endJobs($, ends)
110  await refreshStatus($)
111}
112
113export const register: Register = on => {
114  on('session.start', async ($, e, next) => {
115    await $.command.register({
116      name: 'jobs',
117      description: 'Background job progress (open pane; watch <file> [label]; unwatch <label>; clear)',
118      argumentHint: '[watch <file> [label] | unwatch <label> | clear]',
119      immediate: true,
120    })
121    $.clock.every(POLL_MS, () => {
122      void poll($)
123    })
124    // Earlier versions pinned a status line; a reload must take it down.
125    $.ui.status(undefined)
126    return next(e)
127  })
128
129  on('tool.call', async ($, e, next) => {
130    const ran = await next(e)
131    if (ran.deny !== undefined || ran.isError === true) return ran
132    const now = await $.clock.now()
133
134    if (e.tool === 'Bash') {
135      const started = parseBackgroundStart(ran.text ?? '')
136      if (started !== undefined) {
137        const job: Job = {
138          id: started.id,
139          kind: 'shell',
140          label: shortLabel(e.description ?? e.command),
141          startedAt: now,
142          status: 'running',
143          history: [],
144        }
145        if (started.file !== undefined) job.file = started.file
146        await addJob($, job)
147      }
148    } else if (e.tool === 'Agent') {
149      const result = ran.result as { status?: string; agentId?: string; description?: string } | undefined
150      if (result?.status === 'async_launched' && result.agentId !== undefined) {
151        await addJob($, {
152          id: result.agentId,
153          kind: 'agent',
154          label: shortLabel(`agent: ${result.description ?? 'background'}`),
155          startedAt: now,
156          status: 'running',
157          history: [],
158        })
159      }
160    } else if (e.tool === 'TaskStop') {
161      const id = e.task_id ?? e.shell_id
162      if (id !== undefined) await endJobs($, [{ id, status: 'killed' }])
163    }
164    return ran
165  })
166
167  // Completion: the engine appends a <task-notification> row (and may deliver one) when a task ends.
168  on('session.append', async ($, e, next) => {
169    const text = JSON.stringify(e.message.content ?? '')
170    if (text.includes('task-notification')) await endJobs($, parseTaskEnds(text.replace(/\\n/g, '\n')))
171    return next(e)
172  })
173
174  // Failed/killed rows stay in the band until the person's next prompt.
175  on('prompt.submit', async ($, e, next) => {
176    const now = await $.clock.now()
177    await update($, lastPromptAtom, () => now)
178    return next(e)
179  })
180
181  on('session.receive', async ($, e, next) => {
182    if (e.text.includes('task-notification')) await endJobs($, parseTaskEnds(e.text))
183    return next(e)
184  })
185
186  on('command.run', { command: 'jobs' }, async ($, e) => {
187    const [verb, ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
188    const now = await $.clock.now()
189
190    if (verb === 'watch') {
191      const [file, ...label] = rest
192      if (file === undefined) return { text: 'Usage: /jobs watch <file> [label]' }
193      if (!(await $.fs.exists(file))) return { text: `No such file: ${file}` }
194      await addJob($, {
195        id: `watch:${file}`,
196        kind: 'watch',
197        label: shortLabel(label.length > 0 ? label.join(' ') : (file.split('/').pop() ?? file)),
198        file,
199        startedAt: now,
200        status: 'running',
201        history: [],
202      })
203      void poll($)
204      return { text: `Watching ${file}.` }
205    }
206    if (verb === 'unwatch') {
207      const key = rest.join(' ')
208      await update($, listAtom, prev => (prev ?? []).filter(j => !(j.kind === 'watch' && (j.label === key || j.file === key))))
209      await refreshStatus($)
210      return { text: `Stopped watching ${key}.` }
211    }
212    if (verb === 'clear') {
213      await update($, listAtom, prev => (prev ?? []).filter(j => j.status === 'running'))
214      await refreshStatus($)
215      return { text: 'Cleared finished jobs.' }
216    }
217
218    await $.ui.open({ id: PANE, title: TITLE })
219    const jobs = await read($, listAtom)
220    if (jobs.length === 0) return { text: 'No background jobs.' }
221    return { text: jobs.map(j => `${j.status === 'running' ? 'run ' : 'end '} ${j.label}  ${duration(now - j.startedAt)}  ${progressText(j, now)}`).join('\n') }
222  })
223
224  // Without a prompt rule, a model-facing convention goes unused (see run-ledger's eval: 0/5 → 3/3).
225  on('prompt.compose', async ($, e, next) => {
226    const composed = await next(e)
227    if (!e.tools.includes('Bash')) return composed
228    return { sections: [...composed.sections, { id: 'jobs:progress', text: REPORT_RULE, scope: 'session' }] }
229  })
230
231  // The band above the prompt always has room (the footer tail is dropped when the footer is full).
232  // Draw after whatever is beneath, so run-ledger's line stays on top in either load order.
233  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
234    const below = await next(e)
235    if (e.props.hasSurvey) return below
236    const rows = bandRows(await read($, listAtom), await $.clock.now(), Math.max(20, e.props.bodyColumns - 1), await read($, lastPromptAtom))
237    if (rows.length === 0) return below
238    const { Box, Text } = $.ui.resolve(e)
239    return (
240      <Box flexDirection="column">
241        {below}
242        {rows.map(row =>
243          row.tone === 'failed' ? (
244            <Text key={row.key} color="red">{row.text}</Text>
245          ) : (
246            <Text key={row.key} dimColor={row.tone === 'done'}>{row.text}</Text>
247          ),
248        )}
249      </Box>
250    )
251  })
252
253  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
254    const { Box, Text, Button } = $.ui.resolve(e)
255    const jobs = await read($, listAtom)
256    const now = await $.clock.now()
257    const width = Math.max(20, e.props.bodyColumns - 2)
258    const ordered = [...jobs.filter(j => j.status === 'running'), ...jobs.filter(j => j.status !== 'running').reverse()]
259
260    if (ordered.length === 0) {
261      return (
262        <Box flexDirection="column">
263          <Text dimColor>No background jobs.</Text>
264          <Text dimColor>Started with run_in_background, a background agent, or /jobs watch &lt;file&gt;.</Text>
265        </Box>
266      )
267    }
268
269    return (
270      <Box flexDirection="column">
271        {ordered.map(job => (
272          <Box key={job.id} flexDirection="column" marginBottom={1}>
273            <Box flexDirection="row">
274              <Text bold={job.status === 'running'} dimColor={job.status !== 'running'} color={job.status === 'failed' ? 'red' : undefined} wrap="truncate-end">
275                {`${job.label}  ${duration((job.endedAt ?? now) - job.startedAt)}  ${progressText(job, now)}  `}
276              </Text>
277              {job.status === 'running' && job.kind !== 'watch' ? (
278                <Button key={`stop:${job.id}`} label="stop" onPress={async () => {
279                  await $.tool.call({ tool: 'TaskStop', task_id: job.id })
280                  await endJobs($, [{ id: job.id, status: 'killed' }])
281                }} />
282              ) : null}
283              {job.kind === 'watch' && job.status === 'running' ? (
284                <Button key={`unwatch:${job.id}`} label="unwatch" onPress={async () => {
285                  await update($, listAtom, prev => (prev ?? []).filter(j => j.id !== job.id))
286                  await refreshStatus($)
287                }} />
288              ) : null}
289            </Box>
290            {job.lastLine === undefined ? null : (
291              <Text dimColor wrap="truncate-end">{job.lastLine.slice(0, width)}</Text>
292            )}
293          </Box>
294        ))}
295      </Box>
296    )
297  })
298}
299
hooks/progress.ts 214 lines
1// Pure helpers: parse progress out of a log tail, read the engine's background texts, format. No `$`.
2import type { Job, JobProgress, JobStatus } from '../types'
3
4export const MAX_FINISHED = 8
5// A finished job stays in the status line this long.
6export const RECENT_MS = 120_000
7// Output that has not grown for this long is called out as quiet (possible stall).
8export const QUIET_MS = 180_000
9
10// eslint-disable-next-line no-control-regex
11const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g
12
13// A progress bar redraws one line with \r: the visible line is the segment after the last \r.
14export const visibleLines = (tail: string): string[] =>
15  tail
16    .replace(ANSI, '')
17    .split('\n')
18    .map(line => (line.split('\r').filter(s => s.trim() !== '').pop() ?? '').trim())
19    .filter(line => line !== '')
20
21const UNIT: Record<string, number> = { b: 1, kb: 1e3, mb: 1e6, gb: 1e9, tb: 1e12, kib: 1024, mib: 1024 ** 2, gib: 1024 ** 3 }
22const SIZE = /([\d.]+)\s*(B|KB|MB|GB|TB|KiB|MiB|GiB)\s*(?:\/|of)\s*([\d.]+)\s*(B|KB|MB|GB|TB|KiB|MiB|GiB)\b/i
23const PERCENT = /(?<![\d.])(\d{1,3}(?:\.\d+)?)\s?%/
24const COUNT = /(?<![\d./:-])(\d+)\s*(?:\/|\bof\b)\s*(\d+)(?![\d./:-])/
25
26const clampPct = (n: number) => Math.max(0, Math.min(100, n))
27
28// Lines a job prints to report on itself: `::progress 12/40 uploading`, `::progress 64%`, `::status waiting on CI`.
29const MARK_PROGRESS = /^::progress\s+(\S+)\s*(.*)$/
30const MARK_STATUS = /^::status\s+(.+)$/
31export const isMarker = (line: string): boolean => line.startsWith('::progress ') || line.startsWith('::status ')
32
33const specPct = (spec: string): number | undefined => {
34  const pct = /^(\d{1,3}(?:\.\d+)?)%$/.exec(spec)
35  if (pct?.[1] !== undefined) return clampPct(Number(pct[1]))
36  const count = /^(\d+)\/(\d+)$/.exec(spec)
37  if (count && Number(count[2]) > 0) return clampPct((Number(count[1]) / Number(count[2])) * 100)
38  const size = /^([\d.]+)(B|KB|MB|GB|TB|KiB|MiB|GiB)\/([\d.]+)(B|KB|MB|GB|TB|KiB|MiB|GiB)$/i.exec(spec)
39  if (size) {
40    const done = Number(size[1]) * (UNIT[(size[2] ?? 'b').toLowerCase()] ?? 1)
41    const total = Number(size[3]) * (UNIT[(size[4] ?? 'b').toLowerCase()] ?? 1)
42    if (total > 0) return clampPct((done / total) * 100)
43  }
44  return undefined
45}
46
47// The job's own report wins over anything guessed: the newest ::progress, plus a newer ::status.
48const parseMarkers = (lines: readonly string[]): JobProgress | undefined => {
49  let progress: { at: number; spec: string; msg: string } | undefined
50  let status: { at: number; msg: string } | undefined
51  for (let i = lines.length - 1; i >= 0 && (progress === undefined || status === undefined); i--) {
52    const line = lines[i] ?? ''
53    const p = progress === undefined ? MARK_PROGRESS.exec(line) : null
54    if (p) progress = { at: i, spec: p[1] ?? '', msg: (p[2] ?? '').trim() }
55    const s = status === undefined ? MARK_STATUS.exec(line) : null
56    if (s) status = { at: i, msg: (s[1] ?? '').trim() }
57  }
58  if (progress === undefined && status === undefined) return undefined
59  const msg = status !== undefined && (progress === undefined || status.at > progress.at) ? status.msg : progress?.msg ?? ''
60  const pct = progress === undefined ? undefined : specPct(progress.spec)
61  const text = [progress?.spec, msg].filter(Boolean).join(' ')
62  return pct === undefined ? { text } : { pct, text }
63}
64
65// Newest line first; within a line an explicit % wins, then byte pairs, then N/M counts.
66export const parseProgress = (lines: readonly string[]): JobProgress | undefined => {
67  const marked = parseMarkers(lines)
68  if (marked !== undefined) return marked
69  for (let i = lines.length - 1; i >= Math.max(0, lines.length - 20); i--) {
70    const line = lines[i] ?? ''
71    const pct = PERCENT.exec(line)
72    if (pct?.[1] !== undefined && Number(pct[1]) <= 100) return { pct: clampPct(Number(pct[1])), text: `${Math.round(Number(pct[1]))}%` }
73    const size = SIZE.exec(line)
74    if (size) {
75      const done = Number(size[1]) * (UNIT[(size[2] ?? 'b').toLowerCase()] ?? 1)
76      const total = Number(size[3]) * (UNIT[(size[4] ?? 'b').toLowerCase()] ?? 1)
77      if (total > 0 && done <= total) return { pct: clampPct((done / total) * 100), text: `${size[1]} ${size[2]} / ${size[3]} ${size[4]}` }
78    }
79    const count = COUNT.exec(line)
80    if (count) {
81      const done = Number(count[1])
82      const total = Number(count[2])
83      if (total > 1 && done <= total) return { pct: clampPct((done / total) * 100), text: `${done}/${total}` }
84    }
85  }
86  return undefined
87}
88
89export const lastLineOf = (lines: readonly string[]): string | undefined => {
90  const line = lines.filter(l => !isMarker(l)).pop()
91  if (line === undefined) return undefined
92  return line.length > 160 ? `${line.slice(0, 159)}…` : line
93}
94
95// "Command running in background with ID: bpvctn69i. Output is being written to: /…/bpvctn69i.output."
96export const parseBackgroundStart = (text: string): { id: string; file?: string } | undefined => {
97  const id = /running in background with ID: ([\w-]+)/.exec(text)?.[1]
98  if (id === undefined) return undefined
99  const file = /Output is being written to: (\S+?\.output)\b/.exec(text)?.[1]
100  return file === undefined ? { id } : { id, file }
101}
102
103export type TaskEnd = { id: string; status: JobStatus; exitCode?: number }
104
105// <task-notification><task-id>…</task-id>…<status>completed</status><summary>… (exit code 0)</summary>
106export const parseTaskEnds = (text: string): TaskEnd[] => {
107  const ends: TaskEnd[] = []
108  for (const block of text.split('<task-notification>').slice(1)) {
109    const id = /<task-id>\s*([^<\s]+)\s*<\/task-id>/.exec(block)?.[1]
110    const raw = /<status>\s*([^<\s]+)\s*<\/status>/.exec(block)?.[1]
111    if (id === undefined || raw === undefined) continue
112    const code = /exit code (-?\d+)/.exec(block)?.[1]
113    const status: JobStatus =
114      raw === 'killed' || raw === 'stopped' ? 'killed' : raw === 'failed' || (code !== undefined && code !== '0') ? 'failed' : 'completed'
115    ends.push(code === undefined ? { id, status } : { id, status, exitCode: Number(code) })
116  }
117  return ends
118}
119
120export const duration = (ms: number): string => {
121  const s = Math.max(0, Math.round(ms / 1000))
122  if (s < 60) return `${s}s`
123  if (s < 600) return s % 60 === 0 ? `${s / 60}m` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
124  const m = Math.round(s / 60)
125  if (m < 60) return `${m}m`
126  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
127}
128
129// Rate over the last few minutes of samples; undefined until there is a measurable rise.
130export const eta = (history: Job['history'], now: number): string | undefined => {
131  const recent = history.filter(p => now - p.at <= 600_000)
132  const first = recent[0]
133  const last = recent[recent.length - 1]
134  if (first === undefined || last === undefined || last.pct <= first.pct || last.at <= first.at) return undefined
135  const perMs = (last.pct - first.pct) / (last.at - first.at)
136  return `~${duration((100 - last.pct) / perMs)}`
137}
138
139export const pushHistory = (history: Job['history'], at: number, pct: number | undefined): Job['history'] => {
140  if (pct === undefined) return history
141  const prev = history[history.length - 1]
142  if (prev !== undefined && pct < prev.pct) return [{ at, pct }] // restarted, or a new phase
143  if (prev !== undefined && pct === prev.pct) return history
144  return [...history, { at, pct }].slice(-30)
145}
146
147export const progressText = (job: Job, now: number): string => {
148  if (job.status !== 'running') {
149    const took = duration((job.endedAt ?? now) - job.startedAt)
150    const code = job.exitCode !== undefined && job.exitCode !== 0 ? ` (exit ${job.exitCode})` : ''
151    return `${job.status === 'completed' ? 'done' : job.status}${code} in ${took}`
152  }
153  const parts: string[] = []
154  if (job.progress) parts.push(job.progress.text)
155  const left = job.progress?.pct === undefined ? undefined : eta(job.history, now)
156  if (left !== undefined) parts.push(left)
157  if (job.changedAt !== undefined && now - job.changedAt >= QUIET_MS) parts.push(`quiet ${duration(now - job.changedAt)}`)
158  return parts.join(' ')
159}
160
161export const statusLine = (jobs: readonly Job[], now: number): string | undefined => {
162  const running = jobs.filter(j => j.status === 'running')
163  const shown = running.slice(0, 3).map(j => [j.label, progressText(j, now)].filter(Boolean).join(' '))
164  if (running.length > 3) shown.push(`+${running.length - 3}`)
165  if (shown.length === 0) {
166    const recent = jobs.filter(j => j.status !== 'running' && now - (j.endedAt ?? 0) <= RECENT_MS)
167    for (const j of recent.slice(-2)) shown.push(`${j.label} ${progressText(j, now)}`)
168  }
169  // The engine already prefixes a mod's status with its name.
170  return shown.length === 0 ? undefined : shown.join(' · ')
171}
172
173export const shortLabel = (text: string): string => {
174  const one = text.trim().split('\n')[0] ?? ''
175  return one.length > 28 ? `${one.slice(0, 27)}…` : one
176}
177
178// Keep every running job and the most recent finished ones.
179export const trimJobs = (jobs: readonly Job[]): Job[] => {
180  const finished = jobs.filter(j => j.status !== 'running')
181  const keep = new Set(finished.slice(-MAX_FINISHED))
182  return jobs.filter(j => j.status === 'running' || keep.has(j))
183}
184
185export type BandRow = { key: string; text: string; tone: 'running' | 'done' | 'failed' }
186
187const pad = (text: string, n: number) => (text.length >= n ? text.slice(0, n) : text + ' '.repeat(n - text.length))
188
189// One row per running job, then the recently finished; aligned columns, cut to the band's width.
190// A completed job shows for RECENT_MS; a failed or killed one stays until the person's next prompt.
191export const isShownFinished = (job: Job, now: number, lastPromptAt: number): boolean =>
192  job.status === 'completed'
193    ? now - (job.endedAt ?? 0) <= RECENT_MS
194    : job.status !== 'running' && ((job.endedAt ?? 0) > lastPromptAt || now - (job.endedAt ?? 0) <= RECENT_MS)
195
196export const bandRows = (jobs: readonly Job[], now: number, columns: number, lastPromptAt = 0, max = 4): BandRow[] => {
197  const running = jobs.filter(j => j.status === 'running')
198  const recent = jobs.filter(j => isShownFinished(j, now, lastPromptAt)).reverse()
199  const all = [...running, ...recent]
200  const rows: BandRow[] = all.slice(0, max).map(job => {
201    const word = job.status === 'running' ? 'run ' : job.status === 'completed' ? 'done' : job.status === 'failed' ? 'fail' : 'kill'
202    const elapsed = duration((job.endedAt ?? now) - job.startedAt)
203    const detail = job.status === 'running' ? progressText(job, now) : job.exitCode !== undefined && job.exitCode !== 0 ? `exit ${job.exitCode}` : ''
204    const text = `${word}  ${pad(job.label, 28)}  ${pad(elapsed, 6)}  ${detail}`.trimEnd()
205    return {
206      key: job.id,
207      text: text.length > columns ? `${text.slice(0, Math.max(0, columns - 1))}…` : text,
208      tone: job.status === 'running' ? 'running' : job.status === 'completed' ? 'done' : 'failed',
209    }
210  })
211  if (all.length > max) rows.push({ key: 'more', text: `      +${all.length - max} more · /jobs`, tone: 'done' })
212  return rows
213}
214
types/index.d.ts 30 lines
1export type JobKind = 'shell' | 'agent' | 'watch'
2
3export type JobStatus = 'running' | 'completed' | 'failed' | 'killed'
4
5export type JobProgress = { pct?: number; text: string }
6
7export type Job = {
8  id: string
9  kind: JobKind
10  label: string
11  file?: string
12  startedAt: number
13  status: JobStatus
14  endedAt?: number
15  exitCode?: number
16  progress?: JobProgress
17  lastLine?: string
18  /** when the log last grew, for "quiet 4m" stall hints */
19  changedAt?: number
20  size?: number
21  /** (time, pct) points for the ETA */
22  history: { at: number; pct: number }[]
23}
24
25declare module 'claude-code' {
26  interface PluginState {
27    jobs: { list: Job[]; lastPromptAt: number }
28  }
29}
30