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, or list once.

  • External work gets a mirror watcher — 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

forecast <name> prints the p25–p75 duration band of past successful runs — answerable before starting, which changes whether the job gets started at all.

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 running forever.

Notifications, MCP, and the advisory hook (0.2.0)

  • Notifications — an executable at ~/.claude/progress/notify is the entire configuration: the daemon runs it detached on every done/failed/orphaned/stalled transition (stalls once per episode) with PROGRESS_EVENT, PROGRESS_NAME, PROGRESS_ERROR etc. in the environment. Point it at notify-send, an email sender, anything.

  • MCPscripts/progress_mcp.py is a stdio MCP server exposing progress_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 PreToolUse hook 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; forecast prints the same. run tees 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.