watchjob — start it, supervise it, say so out loud
One command that starts long work, keeps a trustworthy record of it, and announces the end — by voice in the room and on your phone.
scripts/watchjob.sh <name> [--host user@host] [--label "spoken name"] [--speaker media_player.x] -- <command...>
Keep what it says short. A job name is an identifier and is unreadable aloud, so the spoken
label defaults to the name with hyphens as spaces, and --label overrides it with something a
person would actually say. Heard from across a room, short beats precise:
| success | "orca build complete." — plus ", N minutes" only when N ≥ 1 |
| failure | "orca build failed. Exit code 9." |
Elapsed time on a short job is noise, and noise is what makes an announcement get ignored. The second channel still carries the detail — it is read, not heard.
Examples:
# local
scripts/watchjob.sh kernel-suite -- 'cd ~/src/myproject && ./run-tests.sh --full'
# on a build box, announced in the workshop
scripts/watchjob.sh orca-build --host me@buildbox -- \
'cd ~/src/slicer && cmake --build build --config Release -j 14'
Why this exists, and what it refuses to do
A build finished at 15:27 and was reported as "still linking" for the next 70 minutes. Two causes, both structural:
- The status check matched itself.
pgrep -f "cmake --build build"inside an ssh command containing that string always matches, so the answer is always "running". The observer was inside the observed set. - The job was not supervised.
nohup … &inside an ssh session dies when the session ends, which is indistinguishable from a crash.
So this skill never asks pgrep anything and never backgrounds a bare process.
- State comes from
systemctl --user show— it cannot match the question being asked. - The job is a transient systemd unit, owned by the service manager, not by the shell that
launched it. Verified: a 90 s job started over ssh was still
activeafter the launching session had closed. - It notifies on failure too. A watcher that only speaks on success is indistinguishable from one still waiting — which is the bug it exists to prevent. Failures carry the exit code and the last 8 journal lines to the second channel.
What happens
job runstarts the command asclaudejob-<name>.service(local or over ssh).job waitblocks on systemd's own state, polling every 10 s, up to 24 h.- On exit it announces the outcome:
- Sound — Home Assistant
tts.speakto your configuredmedia_player(falling back totts.cloud_say), plus spoken companion-app notifications sent asmessage: TTS+tts_textonalarm_stream, so they are audible even with media volume down. Each target is attempted independently — one dead speaker must not silence the others, which is the entire reason for announcing on more than one device. - A second channel — whatever
WATCHJOB_NOTIFY_CMDpoints at, called as<cmd> <level> <job-name> <body>with leveldoneorerror.
- Sound — Home Assistant
- The start and the stop are appended to the ledger,
~/.claude/state/jobs.ndjson. - It exits with the job's own exit code, so a caller can branch on it.
Configuration
Copy scripts/config.example to ~/.config/watchjob/config and fill in what you have.
Every setting is optional. With no config at all this is still a correct supervisor — start,
wait, status, log, ledger — it simply stays quiet. Announcement channels are a layer on top, and
each is independent: no Home Assistant just means no speech, not a failure.
Prefer WATCHJOB_HA_TOKEN_FILE over an inline WATCHJOB_HA_TOKEN. A token in a config file you
might one day commit is a token you will eventually publish.
Checking on it separately
watchjob.sh blocks. To start something and look in later, use the supervisor directly:
scripts/job run <name> [--host H] -- <cmd...>
scripts/job status <name> [--host H] # 0 active · 1 failed · 2 finished · 3 unknown
scripts/job log <name> [--host H] [n]
scripts/job list [--host H]
scripts/job ledger 20
Never answer "is it still running?" with pgrep -f or ps | grep. Use job status.
Requirements
systemd --useron whichever host runs the job. On a remote host it needs a live user session; the script exportsXDG_RUNTIME_DIRandDBUS_SESSION_BUS_ADDRESSitself, because a non-login ssh shell has neither.python3for the Home Assistant call (stdlib only). Optional — only used ifWATCHJOB_HA_URLis set.- Passwordless ssh to any
--hostyou use.