# Watchjob

> Run a long command under supervision and get told out loud when it ends — starts it as a transient systemd unit (local or over ssh), tracks it authoritatively, then announces the outcome through Home Assistant TTS and a notifier of your choice. Use for builds, sweeps, deploys, long test runs, or any task that outlives one command, and whenever the user says "run this and tell me when it's done", "monitor this build", "watch this job", "notify me when ready", "supervise this". Notifies on failure as well as success.

- Skill: `tommasobbianchi/watchjob` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add tommasobbianchi/watchjob`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tommasobbianchi/watchjob/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- License: MIT
- Author: tommasobbianchi (https://skillmd.com/u/tommasobbianchi)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tommasobbianchi/watchjob

---


# 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.

```bash
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:

```bash
# 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 `active` after 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

1. `job run` starts the command as `claudejob-<name>.service` (local or over ssh).
2. `job wait` blocks on systemd's own state, polling every 10 s, up to 24 h.
3. On exit it announces the outcome:
   - **Sound** — Home Assistant `tts.speak` to your configured `media_player` (falling back to
     `tts.cloud_say`), plus spoken companion-app notifications sent as `message: TTS` +
     `tts_text` on `alarm_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_CMD` points at, called as
     `<cmd> <level> <job-name> <body>` with level `done` or `error`.
4. The start and the stop are appended to the ledger, `~/.claude/state/jobs.ndjson`.
5. 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:

```bash
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 --user` on whichever host runs the job. On a remote host it needs a live user
  session; the script exports `XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS` itself, because
  a non-login ssh shell has neither.
- `python3` for the Home Assistant call (stdlib only). Optional — only used if `WATCHJOB_HA_URL`
  is set.
- Passwordless ssh to any `--host` you use.

