Progress Channel
Work that takes minutes is usually reported by whatever the agent happens to print — a carriage-returned counter in one terminal. That fails three ways: the only way to learn the state is to re-ask (pure overhead), the counter can lie (a stale fragment reads as a stall; submission-order iteration pins it at 0), and work handed to another system — a CI run, a media-server queue, a long download — has no representation at all.
This plugin makes a shared progress channel the default for all three, and the answer to "are we there yet" is a page, never a re-poll.
Install
/plugin install progress-channel@alexmskills
Nothing to configure and no service to manage: the first registered job auto-spawns the daemon.
Architecture: the page server is the tracker
A stdlib-only Python daemon (http.server, zero dependencies) binds 127.0.0.1:7717 and holds live jobs in memory — a single writer, so there is no store and no locking at all. It serves the live HTML view at /, JSON at /jobs, and forecasts at /forecast?name=. Binding the port is the single-instance lease.
Only what must survive a restart is persisted: finished runs append to ~/.claude/progress/history.jsonl — the learning data, cat/jq-able, no database. Live state is memory-only on purpose: if the daemon restarts, producers re-register on their next flush (the producer is the source of truth for its own job); a reboot kills the jobs anyway. And a job never fails because the tracker is sick — an unreachable, unspawnable daemon degrades the job to a warn-once untracked no-op.
The discipline
-
Register anything expected to exceed ~10s — a sweep, a backgrounded command, a call that hands work elsewhere and returns early.
forecast <name>answers "how long has this taken before" prior to starting. -
Never answer a progress question by re-polling and narrating. Open the page, run
watch, orlistonce. -
External work gets a
mirrorwatcher — one small process polling the foreign system into the same channel, exiting when the work goes idle. One list, whatever the source.
Library and CLI
with Job('video integrity', total=10453) as j:
for item in items:
j.step(detail=item, ok=1) # categorical counters: ok / truncated / ...
The job is a context manager — an exception reports failed; only a SIGKILL-class death goes silent, and the daemon’s sweep catches those by checking the producer’s pid directly (it is local, so no heartbeat protocol is needed). Flushes are throttled (every 50 steps or 1s, plus on exit), so a 10k-item loop is a handful of POSTs.
progress.py list # one-shot view
progress.py watch # live TUI
progress.py forecast <name> # pre-start estimate from history
progress.py run --name build -- make all # wrap any command as a job
progress.py mirror --name 'immich metadata' --source immich-jobs \
--poll-cmd '<status cmd>' --interval 30
progress.py daemon # foreground (debug); auto-spawned otherwise
mirror’s live job doubles as a lease: a second watcher for the same `--source refuses to start while the first is alive.
From any script
Anything that can run a command can be a producer — no Python import needed:
T=$(progress.py start --name 'photo import' --total 800)
trap 'progress.py finish $T --fail "aborted"' ERR
for f in *.jpg; do
convert "$f" ...
progress.py step $T --count ok=1 --detail "$f"
done
progress.py finish $T
start prints a token; the cross-invocation state lives in a token file, so step is stateless for the script and keeps the library’s 1s POST throttle. Liveness anchors to the calling script’s pid — a script that dies without finish is swept as orphaned like any other producer.
What makes it more than a table
Duration history is keyed by job shape — name + kind + order-of-magnitude of total — capped at the last 20 runs per shape, so a sweep over videos and one over thumbnails under the same name never blend into a meaningless median.
| Signal | How it is derived |
|---|---|
ETA |
Before ~10% progress: median per-item rate of past same-shape runs. Past it, the current run’s own observed rate takes over. A first run shows no estimate rather than extrapolating. |
Pre-start forecast |
|
stalled ⏸ |
Silent longer than 3× the job’s own learned p95 inter-step gap (floor 30s). A job whose gaps are always long is not stalled — the threshold is learned per job, and a job with no history is never called stalled. |
orphaned ☠ |
The producer’s pid is dead — killed too hard for the context manager to report. The daemon’s sweep adjudicates these actively instead of trusting |
Notifications, MCP, and the advisory hook (0.2.0)
-
Notifications — an executable at
~/.claude/progress/notifyis the entire configuration: the daemon runs it detached on everydone/failed/orphaned/stalledtransition (stalls once per episode) withPROGRESS_EVENT,PROGRESS_NAME,PROGRESS_ERRORetc. in the environment. Point it atnotify-send, an email sender, anything. -
MCP —
scripts/progress_mcp.pyis a stdio MCP server exposingprogress_list/progress_forecast/progress_start/progress_step/progress_finish— a thin face on the daemon’s HTTP API, so agent sessions read the channel as native tools. -
Advisory hook — a shipped
PreToolUsehook nudges (never rewrites) when a Bash command deserves tracking: the channel’s own history says this command shape runs long (the learned threshold), or it matches a static long-runner list / is backgrounded. -
Trend + sparklines — the page’s history section shows per-job duration sparklines and a trend tag ("slowing +40%") when recent same-shape runs drift from the older baseline;
forecastprints the same.runtees the command’s last output lines into the row, so a failed job shows why.
Verify
python3 <plugin>/scripts/test-harness.py — 44 checks against a real daemon on an ephemeral port (auto-spawn, crash semantics, SIGKILL orphan sweep, throttling, shape-keyed history, ETA cutover, restart re-registration, lease refusal, degraded mode, concurrent producers, the shell start/step/finish trio, notify hook, trend detection, MCP handshake, advisory hook). Runs in CI.