
Job Babysitter
- 50 installs
- 339 repo stars
- Updated August 4, 2026
- glebis/claude-skills
Watch a long-running background job (encode, embedding build, batch pipeline, browser daemon) until it finishes or wedges, then deliver a verdict and next command.
About
Starts one background watcher that polls with backoff, detects plateaus, and distinguishes done from stuck, emitting a verdict of done, needs-attention, or blocked. A developer uses it to stop manually polling long jobs and get routed the exact next step when one completes or hangs.
- Plateau heuristics per job type; verdict JSON with next command
- Never runs destructive recovery without asking; honest status reporting
Job Babysitter by the numbers
- 50 all-time installs (skills.sh)
- Ranked #1,082 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/glebis/claude-skills --skill job-babysitterAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 50 |
|---|---|
| repo stars | ★ 339 |
| Last updated | August 4, 2026 |
| Repository | glebis/claude-skills ↗ |
What it does
Watch a long-running background job (encode, embedding build, batch pipeline, browser daemon) until it finishes or wedges, then deliver a verdict and next command.
Files
Job Babysitter
Purpose
Stop manually polling long-running background jobs. Instead of dozens-to-hundreds of ls -lh / ps checks while guessing at completion, start one background watcher that detects the terminal state via plateau heuristics, then routes a verdict — done, needs-attention, or blocked — with the exact next command.
A night-shift nurse for background jobs: it checks vitals on a schedule and escalates only when something is actually wrong.
When to use
Use when a job will run long enough that babysitting it by hand wastes attention:
- Media encodes / transcodes (ffmpeg, video-transcribe, audio extraction)
- Embedding or vector-DB builds (qmd embed, index builds)
- Batch agent / LLM pipelines run in the background
- Browser / scrape daemons (real-browser, agent-browser) prone to hanging
Do NOT use for jobs that finish in seconds, or where a single Bash call already returns the result.
Core principle: stay thin, lean on the harness
This skill orchestrates Claude Code's own primitives — do not reimplement them:
- Start the watcher with `run_in_background: true`. When it exits, the harness
re-invokes the agent automatically — no manual polling loop needed.
- The watcher (
scripts/watch_job.py) owns the deterministic part: poll with backoff,
detect plateau, distinguish done from stuck, emit a verdict JSON.
- The skill's value is the per-job-type heuristics, the safe-recovery playbook,
and notification routing — all in references/playbook.md.
Workflow
1. Identify the job's signals
Determine what can be watched, in order of reliability:
- PID — the process ID (most reliable completion signal). Get it from the job's
launch, pgrep, or ps.
- Output file — a file that grows as the job progresses (e.g. ffmpeg target).
- Log file — a log that gets appended (e.g. an embed progress log).
Read references/playbook.md § "Completion heuristics by job type" to pick flags for the specific job type (ffmpeg, embed, batch, browser).
2. Launch the watcher in the background
Run with run_in_background: true. Always pass --pid when known; add file/log signals as corroboration. Write the verdict to a known path.
scripts/watch_job.py \
--label "lab05 stream encode" \
--pid <PID> \
--output-file /path/to/output.mp4 \
--plateau-bytes 65536 --plateau-polls 5 --stuck-after 120 \
--max-wait 7200 \
--verdict-out /tmp/job-babysitter-<label>.jsonThe watcher prints a one-line JSON heartbeat per poll (tail it for live progress) and writes the final verdict JSON to --verdict-out on exit.
Tuning lives in the playbook; sensible defaults: --interval 10 (backs off to 60), --plateau-polls 4, --stuck-after 300, --max-wait 7200.
3. On watcher exit, read the verdict and route it
The harness re-invokes the agent when the background watcher finishes. Read the verdict JSON. It has status ∈ {done, needs-attention, blocked}, a reason, suggested_next, elapsed time, and final size.
- done → verify the output is real (see the job-type "Done check" in the playbook,
e.g. ffprobe for media, count match for embeds), then proceed with the original task.
- needs-attention → the job plateaued while still alive (possibly wedged). Follow
the recovery playbook: diagnose read-only FIRST. Never kill or run destructive recovery (pkill, WAL checkpoint, VACUUM) without asking the user.
- blocked → the watcher gave up after
--max-wait. Report honestly: "gave up
waiting" ≠ "failed". Offer to re-check or extend the ceiling.
4. Notify per the chosen channel
Default to in-session resume. If the user picked a channel (Telegram, voice/TTS, desktop notification), route per references/playbook.md § "Notification routing". Always include the status emoji, label, elapsed time, and the exact next command.
Guardrails (non-negotiable)
- Never act on a single slow poll. "Stuck" requires plateau AND elapsed past
--stuck-after — the watcher already enforces this before returning needs-attention.
- Ask before any destructive recovery —
pkill,kill, WAL checkpoint,VACUUM,
daemon restart. Diagnose read-only first.
- Report honestly. Distinguish done from "gave up waiting" from "wedged". Never
imply a success the watcher did not observe.
- Poll with backoff, not tight loops — the watcher handles this; never wrap it in
a manual fast-polling loop.
Resources
scripts/watch_job.py— background watcher: plateau detection, stuck-vs-done logic,
verdict JSON. Stdlib only, Python 3.11+.
references/playbook.md— per-job-type completion heuristics, the safe-recovery
table, and notification routing. Load when picking watcher flags or handling a needs-attention/blocked verdict.
Job-type heuristics, recovery playbook, and notification routing
Reference loaded on demand by the job-babysitter skill. Three sections: completion heuristics per job type, the safe-recovery playbook, and notification routing.
---
1. Completion heuristics by job type
Pick watch_job.py flags from the job type. The watcher always prefers a PID exit signal when one is available — pass --pid whenever the job's process ID is known. Add file/log signals as corroboration.
Media encodes (ffmpeg, video-transcribe, audio extraction)
- Best signal: output file size plateau. The target file grows steadily, then
stalls during muxing/finalization, then the process exits.
- Flags:
--output-file <target> --pid <ffmpeg_pid> --plateau-bytes 65536 --plateau-polls 5 - Gotcha: ffmpeg can plateau for several seconds while writing the moov atom at
the end. Keep --stuck-after ≥ 120s so finalization isn't mistaken for a wedge.
- Done check after verdict:
ffprobe <file>returns a valid duration; file is
non-zero and larger than a trivial header.
Embeddings / vector DB (qmd embed, vector index builds)
- Best signal: progress counter in the log ("12,033 done") plus PID exit.
- Flags:
--log-file <embed.log> --pid <pid> --stuck-after 240 - Gotchas (seen in real sessions):
- GPU contention: a foreground vector search competes with the background embed
for the GPU and both crawl. Symptom: long plateau while pid alive.
- WAL bloat /
SQLITE_FULL: the write-ahead log grows huge and blocks writes. - Done check: the embed count equals the source count; no
.wallarger than the DB.
Batch agent / LLM jobs (background agents, batch API, multi-step pipelines)
- Best signal: PID exit, or a terminal marker line in the output/log
(e.g. a results JSON appears, or a "DONE"/"completed" line).
- Flags:
--pid <pid> --log-file <run.log> --max-wait 14400 - Gotcha: batch jobs legitimately idle (waiting on a remote queue). Prefer PID
exit or an explicit completion marker over plateau; raise --stuck-after high.
Browser / scrape daemons (real-browser, agent-browser)
- Best signal: a state/output file the daemon writes, plus liveness of the daemon.
- Flags:
--output-file <state.json> --stuck-after 90 - Gotchas (seen in real sessions): daemon stuck with
EAGAIN; a tab reverts to a
"guest"/logged-out state; the page hangs and needs a direct re-navigation.
---
2. Recovery playbook — SAFE BY DEFAULT
The guardrail is absolute: never kill, restart, or mutate a job without confidence it is truly stuck, and never run a destructive recovery without asking the user first. A single slow poll is not "stuck" — require plateau and elapsed-past---stuck-after, which the watcher already enforces before it returns needs-attention.
When the verdict is needs-attention or blocked, diagnose before acting:
| Symptom | Diagnose (read-only) | Recovery (ASK FIRST — destructive) |
|---|---|---|
| ffmpeg plateau, pid alive | ffprobe partial file; check tail of stderr | Usually just wait longer. Only kill if confirmed hung. |
| Embed stalled, GPU busy | nvidia-smi / ps for a competing search proc | Pause/kill the competing search, not the embed. |
SQLITE_FULL / WAL bloat | ls -lh *.wal; check disk free | WAL checkpoint / VACUUM — ask first, these mutate the DB. |
agent-browser EAGAIN/hung | snapshot the daemon state file; check the port | Restart the daemon; try direct navigation. |
| Browser tab "guest" | snapshot the page | Re-auth the tab (user action) before resuming. |
| Generic process hung | ps, lsof, tail the log | kill only after confirming, and ask first before pkill. |
Always report honestly in the final message: distinguish "done" from "gave up waiting" (blocked) from "wedged" (needs-attention). Never imply success the watcher did not actually observe.
---
3. Notification routing
The verdict carries status ∈ {done, needs-attention, blocked}. Route per the user's chosen channel (configurable; default to in-session resume if unspecified). Always include the status emoji, the label, elapsed time, and the exact next command.
- Telegram — invoke the
telegramskill / plugin to send a message to the user's
saved chat. Use for jobs the user walked away from. Message shape: ✅ <label> done in <elapsed> — <suggested_next>
- Voice / TTS —
elevenlabs-ttsthenafplay(per user's global preference:
read-aloud needs no confirmation). Keep it one short spoken sentence; refer to files loosely ("the encode finished"), never full paths.
- In-session resume — no external ping; the watcher's exit re-invokes the agent.
Print the digest and continue the original work automatically.
- macOS desktop + digest —
osascript -e 'display notification "…" with title "…"'
plus a written digest block in the session.
Verdict → message mapping
done→✅+ confirm output verified + proceed.needs-attention→⚠️+ what's wedged + the read-only diagnosis to run next.
Never auto-run destructive recovery.
blocked→❌+ "gave up waiting after <max-wait>" + how to re-check or extend.
#!/usr/bin/env python3
"""Watch a long-running job until it reaches a terminal state, then emit a verdict.
Designed to be launched as a background process by the job-babysitter skill. The
agent does NOT poll inside its own loop — it starts this watcher in the background
and is re-invoked when the watcher exits. The watcher writes a single verdict JSON
to --verdict-out on exit, and a live heartbeat line to stdout on every poll.
Completion is judged from the most reliable signal available, in priority order:
1. process exit (when --pid is given) — the gold standard for "done"
2. output-file plateau (when --output-file is given) — growth stalls for
--plateau-polls consecutive polls
3. log-file idle (when --log-file is given) — no new bytes appended
"Stuck" is deliberately distinguished from "done": a plateau while the process is
STILL ALIVE past --stuck-after seconds is reported as needs-attention, NOT done,
and NEVER triggers a kill. Recovery is left to the agent + human (see SKILL.md
guardrails).
Stdlib only. Python 3.11+.
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import sys
import time
def pid_alive(pid: int) -> bool:
"""Return True if the process is still running (signal 0 probe)."""
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
# Exists but owned by another user — still "alive" for our purposes.
return True
return True
def file_size(path: str) -> int | None:
try:
return os.path.getsize(path)
except OSError:
return None
def human_bytes(n: int | None) -> str:
if n is None:
return "n/a"
units = ["B", "KB", "MB", "GB", "TB"]
f = float(n)
for u in units:
if f < 1024 or u == units[-1]:
return f"{f:.0f}{u}" if u == "B" else f"{f:.1f}{u}"
f /= 1024
return f"{n}B"
def emit_heartbeat(state: dict) -> None:
"""One compact line per poll, flushed — lets the agent tail progress live."""
sys.stdout.write(json.dumps({"hb": state}) + "\n")
sys.stdout.flush()
def write_verdict(path: str | None, verdict: dict) -> None:
payload = json.dumps(verdict, indent=2)
if path:
# Atomic-ish write so a reader never sees a half-written file.
tmp = f"{path}.tmp"
with open(tmp, "w") as fh:
fh.write(payload)
os.replace(tmp, path)
sys.stdout.write(payload + "\n")
sys.stdout.flush()
def main() -> int:
p = argparse.ArgumentParser(description="Watch a long-running job to a terminal state.")
p.add_argument("--pid", type=int, help="Process ID to watch (most reliable completion signal).")
p.add_argument("--output-file", help="Output file whose growth indicates progress (e.g. ffmpeg target).")
p.add_argument("--log-file", help="Log file whose appends indicate progress.")
p.add_argument("--label", default="job", help="Human label for this job, echoed in the verdict.")
p.add_argument("--interval", type=float, default=10.0, help="Base poll interval seconds (default 10).")
p.add_argument("--max-interval", type=float, default=60.0, help="Backoff cap seconds (default 60).")
p.add_argument("--plateau-polls", type=int, default=4,
help="Consecutive no-growth polls that count as a plateau (default 4).")
p.add_argument("--plateau-bytes", type=int, default=4096,
help="Growth below this many bytes across the window counts as no-growth (default 4096).")
p.add_argument("--stuck-after", type=float, default=300.0,
help="Seconds of plateau-while-alive before reporting needs-attention (default 300).")
p.add_argument("--max-wait", type=float, default=7200.0,
help="Hard ceiling in seconds before giving up (default 7200 = 2h).")
p.add_argument("--verdict-out", help="Path to write the final verdict JSON.")
args = p.parse_args()
if not any([args.pid, args.output_file, args.log_file]):
sys.stderr.write("error: provide at least one of --pid, --output-file, --log-file\n")
return 2
start = time.monotonic()
sizes: list[int] = [] # rolling window of output-file sizes
log_sizes: list[int] = [] # rolling window of log-file sizes
last_progress_ts = start # last time we saw real growth/activity
interval = args.interval
polls = 0
def elapsed() -> float:
return time.monotonic() - start
def finish(status: str, reason: str, suggested_next: str) -> int:
verdict = {
"label": args.label,
"status": status, # done | needs-attention | blocked
"reason": reason,
"suggested_next": suggested_next,
"elapsed_seconds": round(elapsed(), 1),
"polls": polls,
"output_file": args.output_file,
"final_size": human_bytes(file_size(args.output_file)) if args.output_file else None,
"pid": args.pid,
}
write_verdict(args.verdict_out, verdict)
return 0 if status == "done" else 1
while True:
polls += 1
now_elapsed = elapsed()
proc_gone = args.pid is not None and not pid_alive(args.pid)
out_size = file_size(args.output_file) if args.output_file else None
if out_size is not None:
sizes.append(out_size)
sizes = sizes[-(args.plateau_polls + 1):]
log_size = file_size(args.log_file) if args.log_file else None
if log_size is not None:
log_sizes.append(log_size)
log_sizes = log_sizes[-(args.plateau_polls + 1):]
# Did anything grow since last poll? Reset the progress clock if so.
grew = False
if len(sizes) >= 2 and sizes[-1] - sizes[-2] >= args.plateau_bytes:
grew = True
if len(log_sizes) >= 2 and log_sizes[-1] - log_sizes[-2] >= 1:
grew = True
if grew:
last_progress_ts = time.monotonic()
plateau = False
if len(sizes) > args.plateau_polls:
window_growth = sizes[-1] - sizes[-(args.plateau_polls + 1)]
plateau = window_growth < args.plateau_bytes
elif args.output_file is None and len(log_sizes) > args.plateau_polls:
window_growth = log_sizes[-1] - log_sizes[-(args.plateau_polls + 1)]
plateau = window_growth < 1
stalled_secs = time.monotonic() - last_progress_ts
emit_heartbeat({
"poll": polls,
"elapsed_s": round(now_elapsed, 1),
"size": human_bytes(out_size) if out_size is not None else None,
"proc_alive": (not proc_gone) if args.pid else None,
"plateau": plateau,
"stalled_s": round(stalled_secs, 1),
})
# --- Terminal-state decisions, in priority order ---
# 1. Process exited — the most reliable "done".
if proc_gone:
return finish(
"done",
f"process {args.pid} exited after {round(now_elapsed,1)}s",
"Verify the output file is complete and non-empty, then proceed.",
)
# 2. No pid to watch, but the output/log plateaued — treat as done.
if args.pid is None and plateau:
return finish(
"done",
f"output plateaued ({args.plateau_polls} polls, <{args.plateau_bytes}B growth)",
"No PID was watched — sanity-check the output before trusting completion.",
)
# 3. Plateau while the process is STILL ALIVE past the stuck threshold.
# Report, never kill. Recovery is the agent+human's call.
if plateau and not proc_gone and stalled_secs >= args.stuck_after:
return finish(
"needs-attention",
f"no progress for {round(stalled_secs)}s while pid {args.pid} still alive — possibly wedged",
"Inspect the process (logs, GPU, locks). Do NOT kill blindly — see recovery playbook.",
)
# 4. Hard ceiling — give up waiting, but say so honestly.
if now_elapsed >= args.max_wait:
return finish(
"blocked",
f"max-wait {args.max_wait}s exceeded without a terminal state",
"Gave up waiting (not the same as failed). Re-check the job manually or raise --max-wait.",
)
# Sleep with gentle backoff once we're past the first few polls.
time.sleep(interval)
if polls >= args.plateau_polls:
interval = min(interval * 1.3, args.max_interval)
# unreachable
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
sys.stderr.write("\nwatcher interrupted\n")
sys.exit(130)