Live pane of long-running jobs (training, downloads, extractions): progress, ETA, stalls and finishes, read from their logs with no model calls.

Claude Code mods (function-hook plugins) I find helpful. Each folder is one plugin.
Requires a Claude Code build with function-hook plugins (2.1.289 or newer). machine-guard reads macOS tools (sysctl, memory_pressure, ioreg).
git clone https://github.com/joeldg/claude-mods ~/Projects/claude-mods
A pane of your project's running dev servers, so you don't have to ask Claude to restart them.
/servers opens the Servers pane:package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile~/.claude/dev-servers/<project>/.Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).servers: :4000 :5173.lsof and ps every 15s.Puts files you just downloaded into your prompt with one click.
~/Downloads (top level). When a new file arrives (PDF, Markdown, images, 3MF/STL/OBJ, zip, video…), a band appears above the prompt: New in Downloads: paper.pdf, model-b.3mf · 2m ago [Attach] [Dismiss].@"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files./downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).
Sets effort per message, so you don't have to switch it by hand.
avoidModel (a regex such as fable) to send those requests to fallbackModel instead, subagents included./route shows the last decision and the session's counts. /route off and /route on toggle it; /route deep and /route routine force the next turn.effort: low (routine).Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).
A Jobs pane for long-running work: training runs, downloads, extractions.
nohup … > log & launches by itself./watch <log> [label] adds any other log file./ and /Volumes/* (the NAS).jobs: 2 running · 1 stalled./jobs opens the pane, /unwatch <label|done|all> removes jobs.tail, checks processes with ps, and runs df.Settings (in /config): stall minutes (10), refresh seconds (10), how long finished jobs stay (120 min), auto-open (on), which disks to show.
Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.
RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%./busy.When memory is only tight, the job runs and Claude gets a note to start one heavy job at a time.
/busy 3h training a vision model reserves the Mac in every Claude session. /busy off lifts it. The reservation lives in ~/.claude/machine-guard.json, so a training script can write it too: ``bash echo '{"reason":"overnight training","until":'$(( ($(date +%s) + 8*3600) * 1000 ))'}' > ~/.claude/machine-guard.json ``/guard shows what it sees. /guard pause 15m lets heavy jobs through in this session; /guard on resumes the guard.modal run, ssh), tests (pytest) and installs are never treated as heavy.overnight_|nightly_run\.sh.Watches how the other mods behave in real use, without changing them. It is listed first in CLAUDE_CODE_PLUGIN_DIRS, so the other mods' hooks run beneath it.
next.trace), with the mod's name, the event and how long it ran. Slow hooks (over 1.5 s) are recorded too. The first failure of each mod in a session raises a toast./second-opinion, /recall ask) and file writes (folders only, never contents)./mods: a pane with one row per mod: ✓ active, ⚠ failing, ✗ not seen this session, · seen but idle. Each row shows today's counts and last activity, with Details for its recent events. It also says which mods it can't see, if any of them run above it./mods report [24h|7d|30d]: a per-mod report across all sessions, also written to ~/.claude/mods/monitor/report-latest.md for a scheduled review or Claude to read./mods failures [7d]: failures and failed subprocesses only.~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.$.ui.log with wording like "failed" or "could not"): shown in Details and in /mods failures. Three in an hour mark the mod ⚠ and raise one toast. That is how effort-router's per-request hook, which runs inside the response stream where no monitor should sit, reports a failure. It also always sends the request on unchanged./secrets records there) are watched for failures and slow runs, but not counted per run.Settings: alerts (on), slowMs (1500), watchRender (on), watchCommands (on; off stops "mod-monitor" appearing beside other mods' command output), watchAppend (on), retentionDays (30), flushSeconds (60).
Keeps an eye on Modal so idle GPU containers don't burn credits.
Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.alertMinutes (30), repeated at most every 30 minutes./modal opens a pane of apps with state, containers and uptime. Stop asks for Confirm, then runs modal app stop. Nothing is stopped any other way.budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.modal or python3 -m modal. It checks PATH first rather than running a command that can only fail, and stays silent when Modal isn't set up.Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.
gh pr create Claude runs. It polls gh pr view every 60s.PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.--force, never other branches.gh pr checks and the tail of the failed log attached, so you don't paste it./prs lists watched PRs. /prs watch <n|url> and /prs forget <n|all> add and remove them.gh and git, at about one GitHub API call per open PR per minute.Settings:
pollSeconds (60)attachCiLogs (on)logLines (120)deleteRemoteBranch (off): deletes the branch on GitHub too, only while it still points at the merged commit. GitHub's own "Automatically delete head branches" setting does the same job.It never closes issues; put "Closes #N" in PR bodies for that.
Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.
/remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.grep and cat are kept but ranked low.search, expand, recap and list, which run without permission prompts. It checks them when you say "like last time" or "what did we decide", and before asking you something you already settled./recall <query> opens a pane of hits grouped by session. Open shows the conversation around a hit, Attach sends it with your next message, and Copy resume command copies claude --resume <id>./recall last [n] recaps your last session in this repo: last asks, last answer, PRs, commits, open tasks and decisions. Send to Claude attaches it./recall timeline [7d|30d|90d] [all]/recall decisions|commands|files|prs|commits|issues|urls|tasks|notes [query]/recall ask <question> answers from your history with Haiku 4.5, citing sessions. It costs a little usage and sends the matching excerpts to the model./recall stats, /recall reindex, /recall forget session <id>|project <name>|before <date> (asks you to confirm), /recall help./remember <fact>, /remember list, /remember forget <ref>.Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].#214, ABC-12, a file name or a quoted phrase seen in past sessions, a band offers what happened then. Nothing is sent unless you click.OR gives alternatives, "quotes" an exact phrase, and -word excludes. Filters: project:name, kind:decision, since:7d, until:2026-09-30, source:codex, routines:include.~/.claude/recall/index.db, readable only by you, and never goes in a repo.~/.zshrc, ~/.zprofile, ~/.bashrc and ~/.bash_profile, and any literal strings you list in ~/.claude/recall/redact.txt (one per line). Editing that list re-masks the existing index on the next update./recall ask. The first index takes about 2 minutes in the background, with progress on the status line. After that it updates incrementally (about 1s) at session start and every 10 minutes./usr/bin/python3 (Command Line Tools), whose SQLite has FTS5. Nothing else to install.Settings: dbPath, python, sources, includeSubagents (on), includeRoutines (off), updateMinutes (10), relatedBand (on), lastSessionBand (on), maxResults (8), askModel (claude-haiku-4-5-20251001).
Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".
owner, todo, P0 or blockedmain ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it./brief re-gathers now and prints the full summary.git and gh. The band refreshes after a turn at most every 2 minutes.Settings: focus labels, refresh minutes, and whether to brief Claude.
Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.
AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.Routine daily-report finished after 23m · waited on you 2 times.notifyCommand runs a command on the same events, e.g. curl -s -d {message} ntfy.sh/your-topic. {title} and {message} are filled in as single arguments, never through a shell.allowWebReads (off by default): lets routines use WebFetch and WebSearch without asking. It only replaces a prompt; your deny rules still apply, and nothing else is ever auto-allowed./routine shows the routine's name, how long it has run, its waits, and the settings.A Fable review in the background, without switching your session's model. Each run is one Fable call against your usage.
/second-opinion: reviews recent work. On a feature branch that's the branch against the default branch; otherwise the last 12 commits, plus the diff and git status, capped at 60k characters./second-opinion commits 5/second-opinion diff (uncommitted changes)/second-opinion file docs/ADR-007.md/second-opinion <question>: adds a question for Fable to answer first.second opinion: reviewing…. When the review is ready you get a toast, and a pane opens with it, ranked: wrong assumptions, bugs and risks, what's missing, what to do next.~/.claude/second-opinions/<project>/. /second-opinion list lists them, and /second-opinion show [n] reopens one.Settings: model (claude-fable-5-1), effort (high), maxContextChars (60000).
Keeps your "always / never / don't / from now on" instructions alive across compaction.
Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session./clear, so the prompt cache isn't disturbed. A newly saved order also rides along once with your next message./orders lists them. /orders add [project|session] <text>, /orders forget <n>, /orders clear session|project, and /orders export (a Markdown block for CLAUDE.md).Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.
$NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME./secrets test <text> output is masked too./secrets test <text> shows what would be caught. /secrets off and /secrets on toggle it for the session.Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).
Makes Claude's open commands hand 3D files to the right slicer.
full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], orAn open -a BambuStudio … for one becomes open -b com.snapmaker.snapmaker-orca …, with the rest of the command untouched. You get a toast, and Claude gets a note so it doesn't try again.
/slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.open -a <app>, open -a /Applications/X.app and open -b <bundle id>, including variables set earlier in the command (S=… && open -a BambuStudio "$S/x.3mf") and files copied in the same command.Settings:
closePrevious (on): turn it off if you keep your own slicer window open, since the quit request reaches your windows too.fullSpectrumPattern (the regex above)checkContents (on)The quit request goes out when Claude issues the command, before any permission prompt for it.
--plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot~/.claude/settings.json. Put mod-monitor first so it sees the others; CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop sessions pick up edits and show mod failures: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1", "CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/claude-mods/mod-monitor:~/Projects/claude-mods/job-watch:~/Projects/claude-mods/machine-guard:~/Projects/claude-mods/repo-brief:~/Projects/claude-mods/slicer-handoff:~/Projects/claude-mods/pr-autopilot:~/Projects/claude-mods/routine-watch:~/Projects/claude-mods/modal-meter:~/Projects/claude-mods/second-opinion:~/Projects/claude-mods/downloads-drop:~/Projects/claude-mods/dev-servers:~/Projects/claude-mods/standing-orders:~/Projects/claude-mods/effort-router:~/Projects/claude-mods/secret-guard:~/Projects/claude-mods/recall" } } ``Run with Claude Code 2.1.289 or newer; older CLIs ignore per-test settings, so a few tests fall back to defaults.
claude plugin validate job-watch && claude plugin test job-watch
claude plugin validate machine-guard && claude plugin test machine-guard
claude plugin validate repo-brief && claude plugin test repo-brief
claude plugin validate slicer-handoff && claude plugin test slicer-handoff
claude plugin validate pr-autopilot && claude plugin test pr-autopilot
claude plugin validate routine-watch && claude plugin test routine-watch
claude plugin validate modal-meter && claude plugin test modal-meter
claude plugin validate second-opinion && claude plugin test second-opinion
claude plugin validate downloads-drop && claude plugin test downloads-drop
claude plugin validate dev-servers && claude plugin test dev-servers
claude plugin validate standing-orders && claude plugin test standing-orders
claude plugin validate effort-router && claude plugin test effort-router
claude plugin validate secret-guard && claude plugin test secret-guard
claude plugin validate recall && claude plugin test recall
claude plugin validate mod-monitor && claude plugin test mod-monitor
(cd recall/engine && /usr/bin/python3 -m unittest)hooks/register.tsx 412 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Disk, Job, JobState } from '../types'
5import {
6 bar,
7 doneOf,
8 etaFrom,
9 formatCount,
10 formatDuration,
11 isAlive,
12 launchFromCommand,
13 matchToken,
14 outputFileFromText,
15 parseDf,
16 parseProgress,
17 resolvePath,
18} from './parse'
19
20type Engine = EngineInterface
21
22const PANE = 'job-watch'
23const TITLE = 'Jobs'
24/** A job that ends sooner than this was a quick command, not a long job: it leaves silently. */
25const SHORT_MS = 60_000
26
27const jobs = atom({ plugin: 'job-watch', key: 'jobs' } as const, [])
28const disks = atom({ plugin: 'job-watch', key: 'disks' } as const, [])
29
30const hashOf = (text: string): string => {
31 let hash = 0
32 for (let i = 0; i < text.length; i++) {
33 hash = (hash * 31 + text.charCodeAt(i)) | 0
34 }
35 return (hash >>> 0).toString(36)
36}
37
38const shorten = (text: string, max: number): string =>
39 text.length <= max ? text : `${text.slice(0, max - 1)}…`
40
41type Config = {
42 stallMs: number
43 pollMs: number
44 keepDoneMs: number
45 autoOpen: boolean
46 wantedDisks: string[]
47}
48
49let config: Config = { stallMs: 600_000, pollMs: 10_000, keepDoneMs: 7_200_000, autoOpen: true, wantedDisks: [] }
50let timer: { cancel: () => void } | null = null
51let isTicking = false
52let ticks = 0
53let hasOpened = false
54
55function etaText(job: Job, now: number): string {
56 if (job.etaSeconds === null || job.etaAt === null) {
57 return ''
58 }
59 const left = job.etaSeconds - (now - job.etaAt) / 1000
60 return left > 0 ? `ETA ${formatDuration(left)} · ` : 'ETA due · '
61}
62
63function summary(list: readonly Job[]): string | undefined {
64 const count = (state: JobState) => list.filter(job => job.state === state).length
65 const parts = [
66 count('running') && `${count('running')} running`,
67 count('stalled') && `${count('stalled')} stalled`,
68 count('quiet') && `${count('quiet')} quiet`,
69 ].filter(Boolean)
70 return parts.length > 0 ? `jobs: ${parts.join(' · ')}` : undefined
71}
72
73async function refreshDisks($: Engine) {
74 const df = await $.process.run(['df', '-k'], { timeoutMs: 10_000 }).catch(() => null)
75 if (df?.exitCode === 0) {
76 const next: Disk[] = parseDf(df.stdout, config.wantedDisks)
77 await update($, disks, () => next)
78 }
79}
80
81async function readJob($: Engine, job: Job, now: number, ps: string | null): Promise<Job> {
82 if (job.state === 'done') {
83 return job
84 }
85 let next = job
86 const stat = await $.fs.stat(job.log).catch(() => null)
87 if (stat && stat.size !== job.size) {
88 const tail = await $.process.run(['tail', '-c', '8192', job.log], { timeoutMs: 10_000 }).catch(() => null)
89 const reading = parseProgress(tail?.stdout ?? '')
90 const restarted = reading.progress && job.progress && doneOf(reading.progress) < doneOf(job.progress)
91 const baseline =
92 reading.progress && (!job.baseline || restarted) ? { at: now, done: doneOf(reading.progress) } : job.baseline
93 next = {
94 ...next,
95 size: stat.size,
96 changedAt: stat.mtimeMs,
97 lastLine: reading.lastLine || job.lastLine,
98 progress: reading.progress ?? job.progress,
99 baseline,
100 etaSeconds: reading.etaSeconds ?? etaFrom(baseline, now, reading.progress ?? job.progress),
101 etaAt: now,
102 }
103 }
104
105 const alive = next.match && ps !== null ? isAlive(ps, next.match) : null
106 if (alive === true && !next.seenAlive) {
107 next = { ...next, seenAlive: true }
108 }
109 if (alive === false && !next.seenAlive && now - next.addedAt > config.pollMs * 2) {
110 // `ps` never showed the token: it was a bad guess, so judge this job by its log alone.
111 next = { ...next, match: null }
112 }
113
114 const quietFor = now - Math.max(next.changedAt, next.addedAt)
115 const state: JobState =
116 alive === false && next.seenAlive
117 ? 'done'
118 : quietFor > config.stallMs
119 ? alive === true
120 ? 'stalled'
121 : 'quiet'
122 : 'running'
123 return state === 'done' ? { ...next, state, finishedAt: now, etaSeconds: null, etaAt: null } : { ...next, state }
124}
125
126async function tick($: Engine) {
127 if (isTicking) {
128 return
129 }
130 isTicking = true
131 try {
132 ticks += 1
133 if (ticks === 1 || ticks % Math.max(1, Math.round(60_000 / config.pollMs)) === 0) {
134 await refreshDisks($)
135 }
136 const list = await read($, jobs)
137 if (list.length === 0) {
138 $.ui.status(undefined)
139 return
140 }
141 const now = await $.clock.now()
142 const needsPs = list.some(job => job.state !== 'done' && job.match)
143 const ps = needsPs
144 ? await $.process
145 .run(['ps', '-axo', 'command='], { timeoutMs: 10_000 })
146 .then(out => out.stdout)
147 .catch(() => null)
148 : null
149
150 const fresh: Job[] = []
151 for (const job of list) {
152 const next = await readJob($, job, now, ps)
153 const lived = now - next.addedAt
154 if (next.state === 'done' && job.state !== 'done') {
155 if (lived < SHORT_MS) {
156 continue
157 }
158 $.ui.toast(`${next.label} finished after ${formatDuration(lived / 1000)}: ${shorten(next.lastLine, 80)}`, {
159 timeoutMs: 10_000,
160 })
161 }
162 if (next.state === 'stalled' && job.state !== 'stalled') {
163 $.ui.toast(`${next.label} has written nothing for ${formatDuration(config.stallMs / 1000)}`, {
164 timeoutMs: 10_000,
165 })
166 }
167 if (next.state === 'done' && next.finishedAt !== null && now - next.finishedAt > config.keepDoneMs) {
168 continue
169 }
170 if (next.state === 'quiet' && now - next.changedAt > config.keepDoneMs) {
171 continue
172 }
173 fresh.push(next)
174 }
175 await update($, jobs, () => fresh)
176 $.ui.status(summary(fresh))
177
178 const isLong = fresh.some(job => job.state !== 'done' && now - job.addedAt >= SHORT_MS)
179 if (config.autoOpen && !hasOpened && isLong) {
180 hasOpened = true
181 void $.ui.open({ id: PANE, title: TITLE })
182 }
183 } finally {
184 isTicking = false
185 }
186}
187
188function ensureTimer($: Engine) {
189 if (!timer) {
190 timer = $.clock.every(config.pollMs, () => void tick($))
191 }
192}
193
194type NewJob = Pick<Job, 'id' | 'label' | 'log' | 'match'>
195
196async function addJob($: Engine, job: NewJob) {
197 const now = await $.clock.now()
198 const added: Job = {
199 ...job,
200 seenAlive: false,
201 addedAt: now,
202 size: -1,
203 changedAt: now,
204 lastLine: '',
205 progress: null,
206 etaSeconds: null,
207 etaAt: null,
208 baseline: null,
209 state: 'running',
210 finishedAt: null,
211 }
212 await update($, jobs, list => [...list.filter(one => one.log !== added.log), added].slice(-20))
213 ensureTimer($)
214}
215
216export const register: Register = (on, options) => {
217 config = {
218 stallMs: Math.max(1, Number(options.stallMinutes ?? 10)) * 60_000,
219 pollMs: Math.max(2, Number(options.pollSeconds ?? 10)) * 1000,
220 keepDoneMs: Math.max(1, Number(options.keepDoneMinutes ?? 120)) * 60_000,
221 autoOpen: options.autoOpen !== false,
222 wantedDisks: String(options.disks ?? '')
223 .split(',')
224 .map(mount => mount.trim())
225 .filter(Boolean),
226 }
227 timer = null
228 hasOpened = false
229
230 on('session.start', async ($, e, next) => {
231 await $.command.register({ name: 'jobs', description: 'Open the Jobs pane (long-running jobs and disk space)' })
232 await $.command.register({
233 name: 'watch',
234 description: 'Watch a log file in the Jobs pane',
235 argumentHint: '<log path> [label]',
236 })
237 await $.command.register({
238 name: 'unwatch',
239 description: 'Stop watching a job (by label or id), or clear finished ones',
240 argumentHint: '<label|id|done|all>',
241 })
242 ensureTimer($)
243 void tick($)
244 return next(e)
245 })
246
247 // Picks up background Bash tasks and detached `nohup … > log &` launches once they have started.
248 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
249 const ran = await next(e)
250 if (ran.deny !== undefined || ran.isError === true) {
251 return ran
252 }
253 try {
254 const output = e.run_in_background ? outputFileFromText(ran.text) : null
255 if (output) {
256 const match = matchToken(e.command.split(/\s*(?:&&|;|\n)\s*/).pop() ?? e.command)
257 if (match !== null || e.description) {
258 await addJob($, {
259 id: output.split('/').pop()?.replace(/\.output$/, '') ?? hashOf(output),
260 label: shorten(e.description || match || 'background task', 40),
261 log: output,
262 match,
263 })
264 }
265 return ran
266 }
267 const launch = launchFromCommand(e.command)
268 if (launch) {
269 const home = await $.env.get('HOME')
270 const cwd = await $.session.cwd()
271 const base = launch.cwd ? (resolvePath(launch.cwd, home, cwd) ?? cwd) : cwd
272 const log = resolvePath(launch.log, home, base)
273 if (log) {
274 const match = matchToken(launch.segment)
275 await addJob($, {
276 id: hashOf(log),
277 label: shorten(e.description || match || log.split('/').pop() || log, 40),
278 log,
279 match,
280 })
281 }
282 }
283 } catch (error) {
284 $.ui.log(`job-watch: could not register a job: ${String(error)}`, { to: 'debug' })
285 }
286 return ran
287 })
288
289 on('command.run', { command: 'jobs' }, async $ => {
290 ensureTimer($)
291 await tick($)
292 await $.ui.open({ id: PANE, title: TITLE })
293 return { text: summary(await read($, jobs)) ?? 'No jobs are being watched.' }
294 })
295
296 on('command.run', { command: 'watch' }, async ($, e) => {
297 const [path, ...labelWords] = e.args.trim().split(/\s+/)
298 if (!path) {
299 return { text: 'Usage: /watch <log path> [label]' }
300 }
301 const log = resolvePath(path, await $.env.get('HOME'), await $.session.cwd())
302 if (!log || !(await $.fs.exists(log))) {
303 return { text: `job-watch: ${path} does not exist.` }
304 }
305 const label = labelWords.join(' ') || log.split('/').pop() || log
306 await addJob($, { id: hashOf(log), label: shorten(label, 40), log, match: null })
307 await tick($)
308 await $.ui.open({ id: PANE, title: TITLE })
309 return { text: `Watching ${log}` }
310 })
311
312 on('command.run', { command: 'unwatch' }, async ($, e) => {
313 const what = e.args.trim()
314 const before = await read($, jobs)
315 const keep = before.filter(job =>
316 what === 'all'
317 ? false
318 : what === 'done' || what === ''
319 ? job.state !== 'done'
320 : job.id !== what && job.label !== what,
321 )
322 await update($, jobs, () => keep)
323 $.ui.status(summary(keep))
324 return { text: `Removed ${before.length - keep.length} job(s).` }
325 })
326
327 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
328 const { Box, Text, Button } = $.ui.resolve(e)
329 const list = await read($, jobs)
330 const diskRows = await read($, disks)
331 const now = await $.clock.now()
332 const columns = e.viewport?.columns ?? 60
333 const barWidth = Math.max(8, Math.min(24, columns - 40))
334 const color = (state: JobState) =>
335 state === 'running' ? 'green' : state === 'stalled' ? 'red' : state === 'quiet' ? 'yellow' : 'gray'
336
337 return (
338 <Box flexDirection="column" gap={1}>
339 {list.length === 0 && (
340 <Text dimColor>
341 No jobs yet. Background commands and nohup launches that write to a log show up here; /watch adds any log
342 file.
343 </Text>
344 )}
345 {list
346 .slice()
347 .reverse()
348 .map(job => {
349 const age = formatDuration((now - Math.max(job.changedAt, job.addedAt)) / 1000)
350 const figures = job.progress
351 ? `${bar(job.progress.pct, barWidth)} ${job.progress.pct.toFixed(job.progress.pct < 10 ? 1 : 0)}%` +
352 (job.progress.total > 0
353 ? ` ${formatCount(job.progress.done)}/${formatCount(job.progress.total)}`
354 : '')
355 : ''
356 const timing =
357 job.state === 'done'
358 ? `finished ${formatDuration((now - (job.finishedAt ?? now)) / 1000)} ago`
359 : `${etaText(job, now)}log ${age} ago`
360 return (
361 <Box key={`job-${job.id}`} flexDirection="column">
362 <Box flexDirection="row" justifyContent="space-between">
363 <Text bold dimColor={job.state === 'done'} wrap="truncate-end">
364 {job.label}
365 </Text>
366 <Box flexDirection="row" gap={1}>
367 <Text color={color(job.state)}>{job.state}</Text>
368 <Button
369 key={`rm-${job.id}`}
370 label="×"
371 plain
372 onPress={() => update($, jobs, all => all.filter(one => one.id !== job.id))}
373 />
374 </Box>
375 </Box>
376 {(figures || timing) && (
377 <Text dimColor={job.state === 'done'}>
378 {figures}
379 {figures ? ' ' : ''}
380 {timing}
381 </Text>
382 )}
383 {job.lastLine && (
384 <Text dimColor wrap="truncate-end">
385 {job.lastLine}
386 </Text>
387 )}
388 </Box>
389 )
390 })}
391 {list.some(job => job.state === 'done') && (
392 <Button
393 key="clear-done"
394 label="Clear finished"
395 onPress={() => update($, jobs, all => all.filter(job => job.state !== 'done'))}
396 />
397 )}
398 {diskRows.length > 0 && (
399 <Box flexDirection="column">
400 {diskRows.map(disk => (
401 <Text key={`disk-${disk.mount}`} dimColor color={disk.capacityPct >= 90 ? 'red' : undefined}>
402 {disk.mount === '/' ? 'This Mac' : disk.mount.replace(/^\/Volumes\//, '')}: {disk.freeGB} GB free (
403 {disk.capacityPct}% used)
404 </Text>
405 ))}
406 </Box>
407 )}
408 </Box>
409 )
410 })
411}
412hooks/parse.ts 249 lines1import type { Disk, Progress } from '../types'
2
3const ANSI = /\u001b\[[0-9;?]*[A-Za-z]/g
4
5/** The .output file a background Bash task writes to, read from the text the model got back. */
6export const outputFileFromText = (text: string | undefined): string | null =>
7 text?.match(/Output is being written to: (\S+?\.output)\b/)?.[1] ?? null
8
9const LAUNCH_NOISE = new Set(['nohup', 'nice', 'caffeinate', 'env', 'exec', 'time', 'setsid'])
10const GENERIC = new Set(['python', 'python3', 'bash', 'sh', 'zsh', 'node', 'uv', 'poetry', 'ruby'])
11const POLLERS = /^(sleep|tail|until|while|watch|wait|gh|git|ls|cat|echo|ps|pgrep)$/
12
13const splitWords = (segment: string): string[] =>
14 segment.match(/"[^"]*"|'[^']*'|\S+/g)?.map(word => word.replace(/^["']|["']$/g, '')) ?? []
15
16/**
17 * A substring that `ps -axo command=` shows while the job runs: a `-m` module, a
18 * script path, or the executable, after launch wrappers and env assignments.
19 */
20export const matchToken = (segment: string): string | null => {
21 const words = splitWords(segment.split(/\s(?:>>?|2>|&>|<)\s?/)[0] ?? '')
22 let index = 0
23 while (index < words.length) {
24 const word = words[index] ?? ''
25 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || LAUNCH_NOISE.has(word)) {
26 index += word === 'nice' && words[index + 1] === '-n' ? 3 : 1
27 continue
28 }
29 if (word.startsWith('-') && index > 0 && LAUNCH_NOISE.has(words[index - 1] ?? '')) {
30 index += 1
31 continue
32 }
33 break
34 }
35 const rest = words.slice(index)
36 const moduleAt = rest.indexOf('-m')
37 if (moduleAt >= 0 && rest[moduleAt + 1]) {
38 return rest[moduleAt + 1] ?? null
39 }
40 const script = rest.find(word => /\.(py|sh|js|mjs|ts|rb)$/.test(word))
41 if (script) {
42 return script.replace(/^\.\//, '')
43 }
44 const head = rest[0]?.split('/').pop()
45 if (!head || POLLERS.test(head)) {
46 return null
47 }
48 if (GENERIC.has(head.replace(/\d+(\.\d+)?$/, ''))) {
49 return rest[1] && !rest[1].startsWith('-') ? rest[1] : null
50 }
51 // A plain executable (`tar`, `ffmpeg`) is too common alone: keep its first literal arguments.
52 const literal: string[] = []
53 for (const word of rest.slice(0, 3)) {
54 if (/[$`]/.test(word)) {
55 break
56 }
57 literal.push(word)
58 }
59 return literal.join(' ') || null
60}
61
62export type Launch = { log: string; segment: string; cwd: string | null }
63
64/**
65 * A detached launch the command starts (`nohup … > log &`, `… >> log 2>&1 & disown`):
66 * the redirect target and the segment that writes to it, with the last `cd` before it.
67 */
68export const launchFromCommand = (command: string): Launch | null => {
69 const segments = command.split(/\s*(?:&&|;|\n|\|\|)\s*/)
70 let cwd: string | null = null
71 for (let i = 0; i < segments.length; i++) {
72 const segment = (segments[i] ?? '').trim()
73 const cd = segment.match(/^cd\s+("[^"]+"|'[^']+'|\S+)\s*$/)
74 if (cd?.[1]) {
75 cwd = cd[1].replace(/^["']|["']$/g, '')
76 continue
77 }
78 const isDetached = /^nohup\b|\bnohup\s/.test(segment) || /&\s*(disown)?\s*$/.test(segment)
79 if (!isDetached) {
80 continue
81 }
82 const target = [...segment.matchAll(/(?:^|\s)(?:\d?>>?|&>)\s*("[^"]+"|'[^']+'|[^\s&;|]+)/g)]
83 .map(found => (found[1] ?? '').replace(/^["']|["']$/g, ''))
84 .find(path => path !== '/dev/null' && !path.startsWith('&'))
85 if (target) {
86 return { log: target, segment: segment.replace(/&\s*(disown)?\s*$/, '').trim(), cwd }
87 }
88 }
89 return null
90}
91
92/** `~` and `$HOME` expanded, relative paths put under `cwd`; null when another variable is left. */
93export const resolvePath = (path: string, home: string | undefined, cwd: string): string | null => {
94 let out = path
95 if (home) {
96 out = out.replace(/^~(?=\/|$)/, home).replace(/\$\{?HOME\}?/g, home)
97 }
98 if (out.includes('$') || out.startsWith('~')) {
99 return null
100 }
101 if (!out.startsWith('/')) {
102 out = `${cwd.replace(/\/$/, '')}/${out.replace(/^\.\//, '')}`
103 }
104 return out
105}
106
107const UNITS: Record<string, number> = { '': 1, K: 1e3, M: 1e6, G: 1e9, T: 1e12 }
108
109const amount = (value: string, unit = ''): number =>
110 Number(value.replace(/,/g, '')) * (UNITS[unit.toUpperCase().replace(/I?B$/, '')] ?? 1)
111
112const clockSeconds = (text: string): number | null => {
113 if (!/^\d+(:\d+){1,2}$/.test(text)) {
114 return null
115 }
116 return text.split(':').reduce((sum, part) => sum * 60 + Number(part), 0)
117}
118
119const progressOf = (done: number, total: number): Progress | null =>
120 total > 0 && done >= 0 && done <= total * 1.001
121 ? { done, total, pct: Math.min(100, (done / total) * 100) }
122 : null
123
124export type Reading = { progress: Progress | null; etaSeconds: number | null; lastLine: string }
125
126const TQDM = /(\d{1,3}(?:\.\d+)?)%\|[^|]*\|\s*([\d.,]+)([kKMGT]?)\/([\d.,]+)([kKMGT]?)\s*\[[^<\]]*<([\d:]+|\?)/
127const COUNTED =
128 /\b(?:step|iter(?:ation)?|epoch|batch|item|file|object|asset|sample|shard|chunk|scan|mesh|video|frame)s?\b\s*[:=#]?\s*([\d,]+)\s*(?:\/|of)\s*([\d,]+)/i
129const COUNTED_AFTER =
130 /\b([\d,]+)\s*(?:\/|of)\s*([\d,]+)\s+(?:steps|iterations|epochs|batches|items|files|objects|assets|samples|shards|chunks|scans|meshes|videos|frames)\b/i
131const BRACKETED = /\[\s*([\d,]+)\s*\/\s*([\d,]+)\s*\]/
132const SIZED = /([\d.]+)\s*([KMGT])i?B?\s*(?:\/|of)\s*([\d.]+)\s*([KMGT])i?B?\b/i
133const PERCENT = /(\d{1,3}(?:\.\d+)?)\s*%/g
134
135/** Reads the newest progress figure from a log's tail, and its last line. */
136export const parseProgress = (tail: string): Reading => {
137 const lines = tail
138 .replace(ANSI, '')
139 .split(/\r\n|\r|\n/)
140 .map(line => line.trim())
141 .filter(line => line.length > 0)
142 const lastLine = (lines[lines.length - 1] ?? '').slice(0, 200)
143
144 for (const line of lines.slice(-60).reverse()) {
145 const tqdm = line.match(TQDM)
146 if (tqdm) {
147 const done = amount(tqdm[2] ?? '0', tqdm[3])
148 const total = amount(tqdm[4] ?? '0', tqdm[5])
149 const progress = progressOf(done, total) ?? { done: 0, total: 0, pct: Number(tqdm[1]) }
150 return { progress, etaSeconds: clockSeconds(tqdm[6] ?? ''), lastLine }
151 }
152 const counted = line.match(COUNTED) ?? line.match(COUNTED_AFTER) ?? line.match(BRACKETED)
153 if (counted) {
154 const progress = progressOf(amount(counted[1] ?? ''), amount(counted[2] ?? ''))
155 if (progress) {
156 return { progress, etaSeconds: null, lastLine }
157 }
158 }
159 const sized = line.match(SIZED)
160 if (sized) {
161 const progress = progressOf(amount(sized[1] ?? '', sized[2]), amount(sized[3] ?? '', sized[4]))
162 if (progress) {
163 return { progress, etaSeconds: null, lastLine }
164 }
165 }
166 const percents = [...line.matchAll(PERCENT)].map(found => Number(found[1]))
167 const pct = percents.filter(value => value <= 100).pop()
168 if (pct !== undefined) {
169 return { progress: { done: 0, total: 0, pct }, etaSeconds: null, lastLine }
170 }
171 }
172 return { progress: null, etaSeconds: null, lastLine }
173}
174
175/** How far along, in the reading's own unit: its count, or its percentage when it has no count. */
176export const doneOf = (progress: Progress): number => (progress.total > 0 ? progress.done : progress.pct)
177
178const totalOf = (progress: Progress): number => (progress.total > 0 ? progress.total : 100)
179
180/** Seconds left from the average rate since `baseline`, or null when it cannot be told yet. */
181export const etaFrom = (
182 baseline: { at: number; done: number } | null,
183 now: number,
184 progress: Progress | null,
185): number | null => {
186 if (!baseline || !progress || now <= baseline.at) {
187 return null
188 }
189 const perMs = (doneOf(progress) - baseline.done) / (now - baseline.at)
190 return perMs > 0 ? Math.round((totalOf(progress) - doneOf(progress)) / perMs / 1000) : null
191}
192
193/** Whether `ps -axo command=` output shows the job, ignoring our own probes. */
194export const isAlive = (psOutput: string, match: string): boolean =>
195 psOutput
196 .split('\n')
197 .some(line => line.includes(match) && !/^\s*(ps|pgrep|tail -c)\b/.test(line))
198
199/** `df -k` rows for `/` and `/Volumes/*` (or the listed mounts), skipping Time Machine volumes. */
200export const parseDf = (output: string, wanted: readonly string[]): Disk[] => {
201 const disks: Disk[] = []
202 for (const line of output.split('\n').slice(1)) {
203 const found = line.match(/^\S+(?:\s+\S+)*?\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(?:\d+\s+\d+\s+\d+%\s+)?(\/.*)$/)
204 if (!found) {
205 continue
206 }
207 const mount = found[5] ?? ''
208 const isWanted =
209 wanted.length > 0
210 ? wanted.includes(mount)
211 : mount === '/' || (mount.startsWith('/Volumes/') && !mount.includes('Backups of'))
212 if (isWanted && !disks.some(disk => disk.mount === mount)) {
213 disks.push({
214 mount,
215 freeGB: Math.round(Number(found[3]) / 1024 / 1024),
216 capacityPct: Number(found[4]),
217 })
218 }
219 }
220 return disks
221}
222
223export const formatDuration = (seconds: number): string => {
224 const s = Math.max(0, Math.round(seconds))
225 if (s < 90) {
226 return `${s}s`
227 }
228 const minutes = Math.round(s / 60)
229 if (minutes < 90) {
230 return `${minutes}m`
231 }
232 const hours = Math.floor(minutes / 60)
233 return hours < 48 ? `${hours}h${String(minutes % 60).padStart(2, '0')}m` : `${Math.round(hours / 24)}d`
234}
235
236export const formatCount = (value: number): string =>
237 value >= 1e9
238 ? `${(value / 1e9).toFixed(1)}G`
239 : value >= 1e6
240 ? `${(value / 1e6).toFixed(1)}M`
241 : value >= 1e4
242 ? `${(value / 1e3).toFixed(1)}k`
243 : String(Math.round(value))
244
245export const bar = (pct: number, width: number): string => {
246 const filled = Math.round((Math.max(0, Math.min(100, pct)) / 100) * width)
247 return '█'.repeat(filled) + '░'.repeat(width - filled)
248}
249types/index.d.ts 35 lines1export type JobState = 'running' | 'quiet' | 'stalled' | 'done'
2
3export type Progress = { done: number; total: number; pct: number }
4
5export type Job = {
6 id: string
7 label: string
8 /** The file the job writes to: a background task's .output file or a redirect target. */
9 log: string
10 /** A substring of the job's command line that `ps` should show while it runs; null when unknown. */
11 match: string | null
12 /** True once `ps` has shown `match` at least once. */
13 seenAlive: boolean
14 addedAt: number
15 size: number
16 changedAt: number
17 lastLine: string
18 progress: Progress | null
19 etaSeconds: number | null
20 /** When `etaSeconds` was measured; the pane counts down from there. */
21 etaAt: number | null
22 /** The first progress reading this ETA is averaged from. */
23 baseline: { at: number; done: number } | null
24 state: JobState
25 finishedAt: number | null
26}
27
28export type Disk = { mount: string; freeGB: number; capacityPct: number }
29
30declare module 'claude-code' {
31 interface PluginState {
32 'job-watch': { jobs: Job[]; disks: Disk[] }
33 }
34}
35