pi-wake
Programmable event subscriptions for the Pi coding agent: one-shot timers, remote container watches, batch barriers (watch_container_group) and completion-file conditions that wake the same agent session — in-session or via a headless-resume daemon.
Package details
Install pi-wake from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-wake- Package
pi-wake- Version
0.2.4- Published
- Sep 1, 2026
- Downloads
- 1,016/mo · 270/wk
- Author
- xiongqucheng
- License
- MIT
- Types
- extension
- Size
- 463.9 KB
- Dependencies
- 0 dependencies · 2 peers
Pi manifest JSON
{
"extensions": [
"extensions/pi-wake/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-wake
Registry download counts (daily refresh, mirror pulls included — the plugin itself is fully local and reports nothing).
Programmable event subscriptions for the Pi coding agent. The agent names a condition — a point in time, a container exit, a line in a remote log — and pi-wake wakes the same session when it happens:
- While the session is open, the wake is inserted into the running agent loop like a notification (queued behind any in-flight turn, never interrupting it).
- When no session is open, a small daemon resumes the alarm's owner session headlessly (
pi --session <file> --print <facts>), so the agent continues with its full original context, handles the event, and exits.
Polling and timing are deterministic and model-free — no tokens are spent until an event actually fires. Wake messages contain facts only (event kind, status, exit code, bounded log evidence). Timestamps in wake messages, list/list_wakes/check output are shown in the local timezone of the machine running Pi (with the zone name, e.g. GMT+8), not UTC. There is no hidden prompt: the woken agent decides what to do next. It can set new alarms, which makes multi-stage workflows with unpredictable waits (train → evaluate → recertify → …) cheap to run.
Features
- One-shot timers (
after/at), fired once, then paused. - Arbitrary event sources — run your own detector (crawler, sentinel, anything that can print a line) in a container and subscribe to its marker lines with
log-match; see Bring your own detector below. - Remote Docker container watches over SSH:
exit,abnormal,missing,replaced,log-error,log-match,deadline,connection-failure(OR-combined, fingerprint-deduped). - Same-session delivery in both process states (live loop insertion / headless resume).
- Durable at-least-once outbox: a fired event is persisted before delivery, so a crash cannot lose it.
- Multi-session safe (including pi-web hosting several sessions in one process): per-session presence registry, owner-scoped scheduling with a deterministic leader, a cross-process state transaction lock with revision CAS, and atomic wake-delivery claims prevent double-firing, starvation, and clobbering.
- Zero-config for timers. SSH config is needed only for
watch_container.
Install
pi install npm:pi-wake
Or from source: pi install git:github.com/Jasperxjy/pi-wake. Reload Pi afterwards.
Requires Node ≥ 22.19 (aligned with Pi's own baseline; native TypeScript support for the daemon). The in-session extension runs wherever Pi runs.
Quick start
Talk to your agent, or use the tool/command directly:
Set a timer to check the deployment in 30 minutes
Watch the training container and wake me when it exits or errors
Tool JSON (the agent calls wake_alarm; you can also use the slash command):
{"action": "set_timer", "id": "deploy-check", "name": "Check deployment", "after": "30m"}
{
"action": "watch_container",
"id": "train-run",
"name": "Training ended or failed",
"container": "train_job_7",
"events": ["exit", "abnormal", "log-error", "deadline"],
"logPath": "/home/you/experiments/train_job_7.log",
"deadline": "8h",
"statusPoll": "60s"
}
Lifecycle: {"action":"list"}, {"action":"check","id":"…"}, pause, resume, reset, remove, and evidence (opt-in historical log excerpts, see below). Outbox (undelivered wakes) is managed separately: list_wakes, drop_wake (by eventId), purge_wakes (by alarm id), and ack (drop every undelivered wake of an alarm, including all members of a group). remove accepts purgePendingEvents: true to also clear the alarm's undelivered wakes. Slash form: /wake-alarm list, /wake-alarm check train-run, …
Container events
| Event | Fires when |
|---|---|
exit |
container stopped cleanly (exit code 0, not OOM) |
abnormal |
nonzero exit, OOM, dead, or restarting |
missing |
the container name/ID no longer resolves |
replaced |
the name now resolves to a different container ID |
log-error |
newly appended Traceback or *Error |
log-match |
newly appended literal logPattern (not regex) |
deadline |
the relative deadline elapses (scheduled independently of polling) |
connection-failure |
consecutive SSH/probe failures reach the configured threshold |
Creation establishes a log baseline, so historical log content never fires. An explicit logPath is authoritative and must stay under remote.allowedRemoteLogRoots; it never silently falls back to Docker output. policy: "pause" (default) fires once then pauses for the agent to re-arm; policy: "keep" stays active and dedupes stable terminal states.
Batch barriers: watch_container_group
For multi-container batches, create one group instead of N independent alarms:
{
"action": "watch_container_group",
"id": "interp-local",
"name": "Interp local",
"containers": ["ctx-a", "ctx-b", "ctx-c", "ctx-d", "ctx-e", "ctx-f"],
"condition": "all_terminal",
"statusPoll": "30s"
}
Conditions: any_terminal (first terminal member), all_terminal (every member terminal; default), any_abnormal (any abnormal exit/OOM), n_of_m_terminal (with required). Members are created automatically (events exit/abnormal/missing/replaced, keep policy, logTailLines optional) and produce no individual wakes — the group emits exactly one summary wake when the condition is met:
[Wake alarm] Interp local (interp-local)
Group condition met: all_terminal
3/6 terminal; 3 exit 0; 0 abnormal; 0 missing; 0 replaced
Members:
interp-local-1 (ctx-a): exited, code 0
...
Optional coalesceWindow (e.g. 30s) delays a partial-condition fire so the summary can include stragglers; all-terminal always fires immediately. If the outbox has no capacity when the condition is met, the occurrence is frozen (members pause, summary kept) and the group only retries the slot — a restarted container or removed result file can never erase an already-happened event. Once fired, the group and its members pause together; group lifecycle controls members — pause/resume apply to the group AND its members, and reset rebaselines every member (fresh probes, cleared fingerprints), so a new run can never re-fire the previous run's terminal state. remove on a group removes its members in the same transaction; ack on a group drops every member's undelivered wakes. Deleting a member alarm directly is detected as a group integrity failure (the group pauses with a diagnostic instead of counting it as terminal).
Completion files: watch_condition
When an experiment's true completion is a result file rather than a container state:
{
"action": "watch_condition",
"id": "analysis-done",
"name": "Analysis done",
"path": "/data/results/analysis.json",
"condition": "contains",
"value": "\"pass\": true",
"statusPoll": "30s"
}
Conditions: exists, contains (literal substring in the file tail), min_size (byte threshold with minSize). Fires once when satisfied, with a bounded tail excerpt as evidence; reset re-arms.
Stale markers: exists cannot tell a fresh marker from one left behind by a previous run. Have drivers write run-specific markers into a directory that gets cleaned, prefer contains with a run-specific value, or set ignoreBefore (absolute ISO timestamp or relative like "5m") — files last modified before the cutoff never satisfy the condition. Condition evidence carries both clocks: when the probe detected it, and (file mtime: …) when it actually happened.
Bring your own detector: arbitrary event subscriptions
The four alarm kinds are prepackaged sensors — but the sensor slot is open. watch_container never cared what the container does: any process that can print a line becomes an event source. stdout is the interface.
# detector: any loop you like, on the probe host
while true; do
curl -s https://api.example.com/stock | grep -q '"price":49\.' && echo "WAKE: price dropped below 50 at $(date -u +%FT%TZ)"
sleep 60
done
{
"action": "watch_container",
"id": "price-drop",
"name": "Price drop below 50",
"container": "price-watcher",
"events": ["log-match"],
"logPattern": "WAKE:",
"policy": "keep",
"statusPoll": "30s",
"logTailLines": 5
}
Every new WAKE: line fires a wake with the surrounding log lines as evidence (keep policy re-fires; fingerprint dedupe means the same line never fires twice). Pause/resume mutes and unmutes the subscription.
The same trick composes with everything else:
| Goal | Idiom |
|---|---|
| One-shot: "wake me once when X happens" | detector exits 0 only on the real trigger → events: ["exit"] (default pause-on-fire; reset rearms) |
| Repeating: "wake me every time X happens" | long-running detector prints WAKE: … markers → log-match + policy: "keep" |
| Barrier: "wake me when ANY of these detectors fires" (or ALL / N of M) | detectors exit on their trigger → watch_container_group with any_terminal / all_terminal / n_of_m_terminal |
| Structured result: "wake me with the payload" | detector writes a result file → watch_condition with contains + a run-specific value |
What this unlocks (all real, all today, zero plugin code):
- Price / stock / deal watcher — crawl a page or API on a loop, wake on threshold, the agent checks out or drafts the buy note.
- Publication tracker — poll arXiv/search APIs for a keyword or an author, wake on a new hit; the agent reads, triages, files it into the research wiki.
- Competitor changelog & docs watcher — diff a changelog or release feed on a schedule, wake on change; the agent summarizes what moved.
- GPU / disk / service sentinel — a sidecar container checks
nvidia-smi,df, or health endpoints, wakes when a GPU goes idle (grab it) or disk crosses 90% (clean it). - Data-feed staleness watchdog — assert the feed's newest timestamp stays fresh; wake when it doesn't, the agent escalates.
- Experiment anomaly tripwire — a tailer greps training logs for
NaN/divergence markers and wakes long before the cron-based post-mortem would.
Honest edges: logPattern is a literal, not a regex — design distinctive markers (WAKE: prefix) so ordinary output can't false-fire. Detection latency is bounded by statusPoll (1s floor) — right for the internet and infrastructure, wrong for control loops. The detector's bugs are your bugs: keep detection logic dead simple. Wake evidence is untrusted remote text (see includeWakeEvidence) — a compromised detector is a prompt-injection surface. And the detector's egress is the probe host's — one IP doing the crawling.
Status bar and widget
Sessions with a UI (TUI and pi-web alike) render a footer segment (wake: 3 · next train in 4m · daemon live) and, above the editor, a table of active alarms (type word / name / status). Both clear themselves when nothing is active. /wake-alarm show | short | close folds them away (footer-only, or fully hidden — the alarms keep running); the mode persists with the language preference, and {"action":"set_language","language":"en|zh|auto"} switches the display language.
Wake result summaries
logTailLines (1-200) on watch_container / watch_container_group attaches the last N container log lines to exit/abnormal wake evidence, so a wake can answer "what did it print at the end" without an extra SSH round-trip. Evidence stays sanitized, length-bounded, and labeled untrusted.
How waking works
In-session delivery steers into the running turn. A wake that fires while the agent is mid-turn is injected at the next model step (deliverAs: "steer" — the agent loop drains the steering queue before every LLM request), so it interleaves between tool calls like an extra tool result: 思考 → 启动容器 → set_timer → … → 调用工具 → [wake 提醒] → 查看结果. An idle session gets triggerTurn — an immediate new turn.
In-session delivery is echo-confirmed. Handing the message to pi is not delivery: the outbox entry is only removed when the message is echoed into the conversation (message_end carrying the wake's eventId). If the host loses a handed-off message — an abort clears the queued steering messages, a crash evaporates them — the still-durable entry is released and redelivered with backoff (the agent run settling without an echo is the proof of loss). The daemon path is stricter still: its delivery completes only when the woken process exits 0, which already proves persistence. Worst case under this contract is a rare duplicate, never a silent loss — the definition of at-least-once.
session open → in-process scheduler → sendMessage(triggerTurn, steer) → wake lands in the live loop (mid-turn at the next model step)
session closed → daemon (owner offline) → pi --session <owner> --print … → same session continues headlessly
Every alarm created in a session records that session's file as its owner. Coordination uses two simple primitives instead of a global lock lease:
- Presence registry (
.pi/wake-alarm.sessions/): each live session owns exactly one heartbeat file, so registration never contends. A live session schedules only alarms it owns; ownerless (pre-0.1) alarms belong to the deterministic leader (smallest live instance id). The daemon schedules alarms whose owner session is offline — so one open session never starves another session's alarms — and ownerless alarms only when no session is live. - Outbox is independent of alarm state. A fired event becomes a durable outbox entry — a fact that HAPPENED — with its own bounded message snapshot. Entries survive pause/reset of the alarm, and remove stops future events only: undelivered wakes from a removed alarm stay durable and are still delivered (the scheduler tracks the outbox independently of alarm existence). One event kind may occur in many entries (keep policy re-fires), and delivering an entry requires winning a claim token written under the state transaction lock. Session and daemon use the identical claim transaction, so routing overlaps cannot double-deliver, and a crashed claimant's claim simply expires. Outbox lifecycle is explicit:
list_wakesshows undelivered wakes,drop_wake/purge_wakesremove them — removing an alarm never implicitly discards its wakes, and no wake is ever dropped automatically. - Disk state is the source of truth. The runtime keeps only a cache; every action, every scheduler tick, and the daemon's 5-second poll re-reads the state file and merges it into the cache (
reconcile). Alarms created by another session after this process started are adopted automatically, removed alarms disappear, and any alarm whose revision advanced is replaced wholesale. - All state mutations run under a cross-process transaction lock (
.pi/wake-alarm.state.json.lock): the stale-takeover path is designed and stress-tested (including WSL/DrvFS and Windows concurrent-rename interleavings) to prevent a crash-recovery contender from clobbering a live successor's lock — it renames the lock it judged stale to a victim, confirms the victim by BOTH content and inode before deleting it, restores (never drops) any lock it displaced by mistake, and never force-recovers a lock whose owner PID is alive. Per-alarm revision CAS is layered on top: concurrent creates of the same id fail cleanly (alarm already exists), and scheduler decisions are recomputed from the freshest persisted state — a stale scheduler cannot fire a timer that was paused or reset in the meantime. - Runs spawned by the daemon get
WAKE_ALARM_PASSIVE=1: their extension instance serves the tool but never schedules. While a wake run is active the daemon pauses scheduling, then reloads the state file — alarms the woken agent created or changed are picked up. - A wake run that exits 0 clears the outbox record; otherwise it is retried with capped linear backoff. Alarms without an owner session (ephemeral
--no-session) are never spawned; their wakes wait in the outbox for the next interactive session.
Running the daemon
Single instance. Daemons guard against duplicates at startup: a daemon that finds a fresh foreign heartbeat steps down immediately; on a cold-start race, each claims the heartbeat file with its pid and re-verifies after a 1–2.5 s stagger, so at most one survives.
Auto-start. Sessions watch the daemon heartbeat (.pi/wake-alarm.daemon.json, rewritten every 5 s with pid + the last 30 log lines for post-mortem). When you create an alarm and no live daemon exists, the session starts one automatically (detached, WAKE_ALARM_CWD set to the project), and again on the way out when the last session closes. Set "spawnDaemon": false in .pi/wake-alarm.json (or WAKE_ALARM_NO_AUTOSPAWN=1) to manage the daemon yourself. Without a daemon, wakes that fire while all sessions are closed simply wait in the outbox until the next session starts.
pi-wake-daemon # from the project directory; installed as a bin by the package
Or keep it alive with your service manager, e.g.:
# Windows
schtasks /create /tn "pi-wake" /sc onlogon /tr "pi-wake-daemon"
# systemd --user
[Service]
ExecStart=pi-wake-daemon
WorkingDirectory=/path/to/project
Restart=on-failure
Daemon environment overrides:
| Variable | Effect |
|---|---|
WAKE_ALARM_CWD |
Project directory (default: process cwd) |
WAKE_ALARM_CONFIG_PATH / WAKE_ALARM_STATE_PATH |
Override config/state locations |
WAKE_ALARM_PI_COMMAND |
pi command override; a .js path is run with the current Node |
WAKE_ALARM_SPAWN=0 |
Record wakes to the outbox only; never resume sessions |
WAKE_ALARM_SPAWN_DRY_RUN=1 |
Log the resume command instead of running it |
On Windows the daemon unwraps the npm pi.cmd shim and runs the CLI script with the current Node (modern Node refuses to spawn .cmd without a shell).
Configuration
.pi/wake-alarm.json in the project directory — entirely optional unless you use a remote watch (watch_container, watch_container_group, or watch_condition); timers and local lifecycle/outbox/UI actions need no remote config:
{
"remote": {
"host": "192.168.1.10",
"user": "you",
"port": 22,
"identityFile": "../keys/id_ed25519",
"allowedRemoteLogRoots": ["/home/you/experiments/"],
"sshAttempts": 3,
"sshBackoffMs": 1000,
"connectTimeoutSeconds": 10,
"maxConsecutiveFailures": 3
},
"statusPoll": "60s",
"maxLogBytes": 65536,
"maxEvidenceChars": 1000,
"maxOutboxEntries": 1000,
"maxOutboxEntriesPerAlarm": 100,
"piCommand": null,
"spawnOnWake": true,
"spawnDaemon": true,
"uiLanguage": "auto",
"runTimeout": "30m",
"headlessTrust": "saved",
"includeWakeEvidence": true
}
uiLanguage: display language for the status bar and widget —"auto"(system locale; zh* → Chinese, otherwise English),"en", or"zh". Theset_languagetool action switches it per project and persists the choice;/wake-alarm show | short | closecontrols how much of the UI is visible (footer+table / footer only / hidden) independently of the alarms.identityFileis resolved relative to the.pi/directory that holds the config (write"../keys/id_ed25519"for a key stored outside.pi/); private key paths only —password/passphrase/privateKeyfields are rejected. The tool error for a missingremotesection embeds this minimal shape, so the schema is discoverable without the README.allowedRemoteLogRootsconstrains which remote log files may be read (realpath-checked remotely).- A headless wake run that exceeds
runTimeoutis terminated in two phases: a graceful termination request, a grace period, then a force-kill (process tree on Windows viataskkill /T /F). The delivery slot is only released after the woken Pi has actually exited (or the force-kill fallback deadline fires), so a retry cannot start a second Pi on the same session file while the old one still lives. headlessTrustcontrols project trust for headless wake runs:"saved"(default) passes no approval flag, so a woken run only loads project resources when a saved Pi trust decision ordefaultProjectTrustallows it;"always"adds--approve, trusting project resources on every wake — convenient for full automation, weaker for unattended security.includeWakeEvidence: whenfalse, wake messages contain no remote log excerpts (only the factual event fields), keeping untrusted log text out of the prompt entirely; the woken agent can still fetch the stored evidence on demand with{"action":"evidence","id":"…"}— evidence is returned only on that explicit request, capped at 8000 characters and labeled as untrusted remote data.maxOutboxEntries/maxOutboxEntriesPerAlarmbound the undelivered-wake backlog (defaults 1000 / 100; if onlymaxOutboxEntriesis set, the per-alarm default follows it). When a new occurrence would exceed a cap, the producing alarm is paused with an explicitoutbox overflowreason, and the overflow-causing occurrence is NOT consumed: its fingerprint/cursor stay unadvanced, so once capacity is freed withdrop_wake/purge_wakesand the alarm isresumed, the very same event fires again and produces its wake (at-least-once). Existing entries are never discarded automatically.checkevaluates and acknowledges the current condition: a due timer is marked fired and a newly-detected container event is consumed, and no wake is generated — it is a management action (available from any session) for explicitly observing/acknowledging state, not a status probe. Useevidenceto retrieve log text afterwards; the owner gets no automatic wake.statusPoll(and thestatusPollrequest field) has a hard 1-second minimum, so an agent cannot configure a per-millisecond SSH probe loop.- Unknown fields are rejected, so typos fail loudly instead of being silently ignored.
Security notes
- Headless wake runs respect Pi's project-trust model by default (
headlessTrust: "saved", no--approve). A wake that fires without a saved trust decision runs without project extensions/skills; the wake message itself is still delivered and the agent can act with built-in tools. Set"headlessTrust": "always"only if you accept re-granting project trust on every unattended wake.spawnOnWake: false/WAKE_ALARM_SPAWN=0turns spawning off entirely. - Log evidence in wake messages is untrusted remote content; it is sanitized, length-bounded, and labeled
untrusted data. Treat it as data, not instructions. - Runtime state lives in
.pi/wake-alarm.state.json(git-ignore it). Do not point two daemons at the same project.
Development
npm test # unit + multi-session runtime + multi-process integration tests
npm run typecheck # tsc --noEmit (strict, erasableSyntaxOnly)
npm run build # emit dist/ (compiled daemon bin; prepack runs this automatically)
Layout: core.ts (pure alarm/event logic) · runtime.ts (config, SSH probe, scheduler, state transaction lock + CAS, wake claims) · presence.ts (presence registry / leadership) · index.ts (Pi extension shell) · daemon.ts (standalone scheduler/resume host) · scripts/build.mjs (emits dist/ — the daemon bin runs under plain Node, which refuses type stripping for node_modules, so it ships as compiled ESM; the extension itself is loaded by Pi via jiti and ships as TypeScript).
Honest limitations
- The daemon is auto-started by sessions but not supervised: an OS reboot still needs a service manager (schtasks/systemd) to bring it back before the next session opens. Roadmap: one global daemon serving multiple projects.
- Fired alarms are never garbage-collected. A triggered one-shot timer or satisfied condition stays in the state file until
removed — deliberate while the project is young (post-mortem inspection beats auto-deletion during debugging). Planned: opt-in GC for "fired + all wakes delivered + older than N days" once usage stabilizes. - Wake evidence is remote-produced text and is included in wake messages by default (bounded, labeled untrusted). This is fine for experiment environments you control; point
includeWakeEvidence: falseat it before pointing pi-wake at hosts you do not trust. - The daemon delivers one wake at a time (deliberate, to keep state writes single-writer during a wake run); multiple alarms firing together are delivered sequentially.
- A crashed session's presence record takes up to 60 s to expire, so daemon takeover for its alarms lags by at most that window; clean exits are immediate.
- Alarms are project-global objects: delivery is owner-scoped, but management (
list/pause/reset/remove) is available from any session — treat them like cron entries, not private session data. Alarm state is operational project state, not session-history state: rewinding or forking a Pi conversation does not roll back alarms that were already created. - Tested against Pi 0.83.x on Node 22.19+ / 24.x (see CI). Requires Node >= 22.19 to match Pi's own baseline.
- One-shot timers only (no recurring cron) — recurring schedules are deliberately out of scope; see pi-loop / pi-scheduler for in-session recurrence.
- The headless resume path depends on Pi's
--session/--printCLI surface (and--approveonly withheadlessTrust: "always"); track upstream changes when upgrading Pi. - Container watching is read-only over SSH (docker inspect + bounded log reads); it never mutates the remote host.
License
MIT