omp packages

pi-job-listener0.1.6

Give long-running processes back to the agent: hand back a pointer instead of blocking the turn, then wake it when the job actually needs attention.

GitHub Actions Published 9 days ago MIT ~1K downloads/mo

Untested on omp

This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.

Did it work for you?

Result

One report per person. A new report replaces your old one.

Reports 0

No reports yet.

Readme

pi-job-listener

Your agent stops waiting for things that are already dead.

When an agent runs a slow command, it does the intuitive thing: it waits. So it sets a generous timeout — 300 seconds, 600 seconds — and blocks the whole turn on it. If the build fails at second three, that is not discovered at second three. It is discovered at second three hundred, when the timeout finally expires and the shell hands back a wall of output with the failure buried at the bottom. The agent sat there for five minutes watching a process that had already given up.

pi-job-listener replaces the bash tool so that does not happen.

  • A command that finishes quickly behaves exactly as it does today.
  • A command still running after ~5 seconds hands the agent a pointer — job id, state, log path — and the agent moves on instead of blocking.
  • The agent is woken the moment the job needs it: it exited, it failed, it hit its timeout, or it has gone quiet without printing anything. A doomed process is noticed in seconds, not at the timeout.

The wake message is never a summary. It is a pointer, and the agent reads the log itself.

[job-1] exited with code 1 after 7s · 5 lines
command: pytest -q
intent: run the test suite
log: ~/.pi/agent/jobs/20261008-073143/job-1.log
Read the log if you need it. Do not re-run this command.
Look at job-1 before replying. Nothing else here needs a response.

That closing line is computed from the outcomes, because a wake is not always a summons. A job that exited cleanly is news, not work — the agent should carry on rather than write up a job it already knows about. So a clean finish ends with "No reply needed — nothing here changes what you were doing. Do not summarise this wake.", and a batch containing a failure names the jobs worth looking at and says the rest can be ignored.

Install

pi install npm:pi-job-listener

Or from a checkout, which loads live from the working tree:

pi install ./pi-job-listener

Optional: a local Laya server enables the gate, which reads ambiguous output and decides whether it is worth waking the agent for. Without it the gate stays idle and everything else works. See Configuration.

Wake policy

Event What happens
command finishes inside the grace window (~5 s) output returned inline, nothing to wake for
job exits wake, always
job exits non-zero wake, always
job exceeds its timeout killed, then wake — the wake says whether the kill was confirmed
job prints nothing for stall_seconds (default 10) wake — the job keeps running; the agent reads the log and kills it via the jobs tool if it is stuck
job printed something and is still running Laya gate, shadow mode — logged, not acted on

Every wake ends with a line saying whether it needs a reply. And a stall for one job is not re-reported until the job has been quiet for several times its threshold — otherwise a job that prints more slowly than the threshold reports a stall on every pause, which one measured job did six times in two minutes.

A timeout kill is verified, not assumed: the kill is attempted, waited out, retried once, and the wake says what the check could see. KILL UNCONFIRMED means something is provably still alive — the shell itself, or a process still holding the job's output, which is how an orphaned dev server kept writing to its log after being reported as finished. KILL UNVERIFIED means nothing visible survived, but the whole tree could not be seen, so a child that re-parented past the shell would leave no trace. On Windows that is the honest answer for every timeout kill: taskkill /T walks recorded parent pids, and a launcher that exits (bash re-execs, npm.cmd's cmd.exe) leaves its children unreachable to that walk. If a port still answers after KILL UNVERIFIED, that answer is the orphan — kill it by pid rather than reading it as proof the job is healthy.

Terminal events bypass the gate on purpose: a model that says "wait" on a finished job strands the agent. The gate's worst case is a missed optional wake, never an agent asleep on a dead process.

Silence is the one signal that is reported rather than acted on, because the supervisor only sees what reaches the command's stdout — a command piped into something that buffers, or redirected to a file, looks exactly like a hang, and so does a build that pauses to think. Killing on that signal destroys work; reporting it costs one turn. A stall is reported once per quiet period: the next report waits for the job to say something and fall quiet again, so a five-minute silent build produces one wake, not a wake every ten seconds. The clock restarts when a job is handed back, so a command that printed nothing at all does not report a stall in the same instant as the pointer describing it.

Shadow mode

The gate answers every sample and its answer is written to the event log, but nothing acts on it yet. Zero-shot Laya missed the interactive-prompt case — the one that matters most, because a job waiting on input hangs forever — and its confidence never exceeded 0.14, so it has to earn its place from real labelled events before it is allowed to wake anyone.

Each session writes ~/.pi/agent/jobs/<timestamp>/events.jsonl, one JSON object per line:

{"event":"gate","job":"job-2","shadow":true,"choice":"ignore","confidence":0.0436,
 "probabilities":{"wake":0.1966,"wait":0.375,"ignore":0.4285},
 "stateTokens":200,"expectedTokens":118,"truncated":false,"starved":false,"latencyMs":369}
{"event":"terminal","job":"job-2","reason":"exited","exitCode":0,"lines":23,"seconds":8}

Joining the gate line to the terminal line is the training set: was ignoring this output correct? The valuable rows are the misses — ignore followed by an exit code that is not zero.

Fine-tuning is laya-train from that JSONL once a few hundred rows exist.

Tools

  • bash — supervised shell. Extra parameters: stall_seconds, timeout.
  • jobs — list / status / tail / kill.
  • /jobs — the same list in the transcript.

Reattaching after a restart

Every job is written to ~/.pi/agent/jobs/registry.jsonl when it starts and updated when it ends. A later session reads that file on start:

  • a job whose pid is still alive is adopted — its log is polled for growth, its pid for liveness, and a stall is raised if the log stops growing. There is no exit code, because the process was not this session's child, and the wake says so.
  • a job whose pid is gone produced a wake immediately: it ended while nobody was watching.

Without this, a job that outlives a session is lost, which is the one thing a supervisor must not do.

Training data

node node_modules/jiti/lib/jiti-cli.mjs scripts/build-training-data.ts --out training-data.jsonl

Reads every session's events.jsonl, joins each gate sample to the terminal event that followed it, and writes {text, label} rows for laya-train. The labelling rule:

  • a job that ended cleanly — every sample is a confirmed negative (wait)
  • a job that failed, stalled or timed out — only the last sample before the terminal event is the run-up to trouble (wake); earlier samples are dropped, because labelling ordinary progress as wake would teach the gate to cry wolf
  • truncated or starved samples are skipped rather than guessed at

It prints the class balance and the shadow gate's current agreement rate. Under 100 samples it tells you to go and use it rather than write more code.

The board

While anything is running, the extension draws a small board above the editor and a status line:

jobs · 1 running
▶ job-2  pnpm build --filter web        14s          63 ln
           ↳ transforming (128) src/components/Chart.tsx
  • ▶ running, with elapsed time ticking once a second and switching to quiet 14s when the job has gone silent for longer than its stall threshold — the same condition that produces a stall wake.
  • The newest line each job printed, so the board says what a job is doing without opening its log.
  • ⚠ needs a look when the watcher is concerned about what the job is printing.
  • Only jobs still running appear. A finished job's outcome is already in the transcript, as the tool result and as the wake, so the board never becomes a history of things that are over: when the last job ends, the board and the status line both disappear.

The board deliberately shows no model names, no mode names and no confidence numbers. A confidence that has not been calibrated reads as precision the watcher does not have, and which component reached a judgement is not something anyone watching a build needs to know.

Configuration

Variable Default Meaning
PI_JOB_LISTENER_GRACE_MS 5000 How long a command may run before it is handed back as a job
PI_JOB_LISTENER_STALL_SECONDS 10 Default silence before a job is treated as stalled
PI_JOB_LISTENER_GATE on off disables the gate entirely
PI_JOB_LISTENER_GATE_URL http://127.0.0.1:8000/v1/systemone Local Laya server
PI_JOB_LISTENER_GATE_MODEL english Laya checkpoint. jev-latest is rejected — use a Laya name
PI_JOB_LISTENER_GATE_INTERVAL_MS 10000 Minimum gap between gate samples for one job
PI_JOB_LISTENER_LAYA_CMD unset Command that starts a local model server. Set it and a session starts one and stops it again; leave it unset and an existing server is used, or the gate stays idle.
PI_JOB_LISTENER_LAYA_READY_MS 30000 How long to wait for a started server to answer /health

The local model server

Setting PI_JOB_LISTENER_LAYA_CMD means nobody has to remember to start a server before working, or to stop it afterwards:

# macOS / Linux
export PI_JOB_LISTENER_LAYA_CMD="laya-serve"
# Windows, with Laya installed in its own venv
set PI_JOB_LISTENER_LAYA_CMD=C:/Users/you/tools/laya/.venv/Scripts/laya-serve.exe

Three rules keep this boring:

  • Session start never waits for it. The server is resolved in the background; a session opened to run ls should not sit behind model weights loading.
  • Only the server this session started is stopped. Anything already listening belongs to somebody else — a different checkpoint, a manual run — and is left alone.
  • A command that dies is noticed. If the process exits immediately, the gate reports itself unavailable instead of waiting out the readiness timeout.

A server started here is stopped on session shutdown, along with nothing else: jobs outlive sessions on purpose, because killing a twenty-minute build when a session ends is worse than an orphan.

Diagnostics

Three scripts answer questions about the package rather than about the code:

npm run training:data   # the labelled gate samples this machine has accumulated
npm run compare         # scores both candidate gate models on those samples
npm run bench           # what the supervisor costs per command

Measured over 117 samples from this machine (12 wake, 105 wait) — identical question, identical state, both models out of the box:

accuracy recall (wake) precision TP/FP/FN/TN p50
always answer "wait" 89.7% — — — —
local model 88.9% 16.7% 40.0% 2/3/10/102 371 ms
remote (Jev) 74.4% 66.7% 23.5% 8/26/4/79 181 ms

Neither is a usable gate untouched, and they fail in opposite directions. The local model sits below the do-nothing baseline: it says "wait" almost always and catches 2 of 12 failures, so it carries no signal at all. The remote one catches 8 of 12 but fires on 26 of 105 clean runs — as a gate that is a spurious wake every four jobs. That measurement is why the gate is in shadow mode.

npm run compare prints this table for whatever the sample set has become; it needs a local server for one half and Jev credentials for the other, and it uploads the sample text to the provider for the remote half, because the samples are job output.

Develop

node_modules is a junction to the pi install, so the peer dependencies resolve without a download.

node node_modules/jiti/lib/jiti-cli.mjs scripts/harness.ts      # job runner, gate, registry
node node_modules/jiti/lib/jiti-cli.mjs scripts/load-check.ts   # full extension against a stub pi API
node scripts/live-test.mjs                                      # a real pi session over RPC

scripts/live-test.mjs is the one that matters for any change to the wake path: it starts pi in RPC mode with the extension loaded, prompts it to run a long command, and watches for a turn that starts by itself. Print mode cannot test this — it exits as soon as the agent settles.

scripts/fake-job.mjs provides deterministic stand-ins: fast, build, fail, quiet, hang.

Performance (measured 2026-10-08 on this machine)

short command (echo hi), run directly 17 ms
short command, through the supervisor 35 ms
overhead added per command ~18 ms
pointer returned for a job that outlives the grace window grace window + ~15 ms
wake delivered after the process exits ~103 ms
disk per command one log file (~3 bytes for echo hi)

The first measurement of that overhead was 43 ms. waitForGrace polled on a 50 ms timer, so a command that exited in 10 ms was only noticed on the next tick. It resolves on the process's own exit event now, which removed more than half of it; the rest is spawning a shell and opening the log.

Known limits (prototype)

  • An adopted job has no exit code: the process belonged to an earlier session, so only its pid and its log are available. A recycled pid could be mistaken for a live job; the wake reports the exit code as unknown rather than inventing one.
  • Jobs survive session_shutdown on purpose — killing a 20-minute build because a session ended is worse.
  • A managed model server does not survive it: a server this session started is stopped on shutdown. If pi is killed rather than shut down, that stop never runs and the server is orphaned — the same class of problem the job registry solves for jobs, and not solved here. session_shutdown firing on a live path has also not been verified end to end, only its two halves separately.
  • Output is not streamed to the transcript while a job runs detached; it goes to the log only.
  • A Windows kill cannot be verified. taskkill /T walks recorded parent pids, so a launcher that exits can leave its children unreachable, and neither the walk nor its exit code reports what it missed — the wake says KILL UNVERIFIED rather than claiming the tree is gone. A job object (CreateJobObject + AssignProcessToJobObject with kill-on-close) is the one mechanism that both contains the tree and can be asked what is left in it; the extension does not use one yet, and the check above is what is available without it.
  • bash is overridden for every session the extension is loaded in.
  • Every command writes a log file, inline ones included, and nothing prunes them: a long session leaves a directory of tiny files under ~/.pi/agent/jobs/. The in-memory job map is kept for the whole session too (~20 KB per job, mostly the 200-line tail buffer), so neither the disk nor the memory side of a session is bounded yet.

Verified

  • 2026-10-08: a real pi session (RPC mode) detached a job at the grace window, then started a new turn on its own 11.8 s in, carrying the pointer and nothing else.
  • 29 self-checks across harness.ts and load-check.ts.