Process notifications
This document describes how process lifecycle and log-watch events become
notifications that reach the agent, which user options control each path, and
which paths bypass user config. It covers the event flow only — UI rendering
(/no-block, dock, logs overlay) is out of scope.
There are two layers:
- NotificationService (
extensions/processes/notifications/service.ts) listens to theProcessManagerevent bus, classifies the end, resolves an attention level, and emits a language-neutral payload onCHANNELS.NOTIFICATION. - Delivery listener (
extensions/processes/handlers/notifications.ts) subscribes toCHANNELS.NOTIFICATION, applies log-match rate limiting, and converts each payload into a persisted Pi custom message viasendProcessNotificationMessage.
UI extensions (processes-logs, processes-dock) also observe
CHANNELS.NOTIFICATION for display concerns (e.g. log-match highlighting) but
do not affect delivery.
Delivery timing
Generic turn notifications use Pi's steer path. They wake an idle agent or
reach an active run after its current tool calls finish. No Block lifecycle
notifications use followUp so a completed finite job does not redirect an
active run. context and emitted ignore notifications use nextTurn. They do
not wake or steer the agent and enter the conversation with the next user prompt.
Deferring non-turn notifications also preserves tool-call ordering. Appending a
custom message during tool execution would place it between the assistant
tool_use and its tool_result, which Anthropic rejects.
Per-process notify config
Registered by the process start and process update tools
(normalizeNotifyConfig in extensions/processes/tools/notify.ts):
| Field | Type | Default |
|---|---|---|
onSuccess |
"turn" | "context" | "ignore" |
"turn" |
onFailure |
"turn" | "context" | "ignore" |
"turn" |
onKilled |
"turn" | "context" | "ignore" |
"context" |
logMatches |
LogMatcherConfig[] |
[] |
Each LogMatcherConfig:
| Field | Type | Default |
|---|---|---|
pattern |
string (≤500 chars, non-empty) |
required |
mode |
"literal" | "regex" |
"literal" |
stream |
"stdout" | "stderr" | "both" |
"both" |
repeat |
boolean |
false |
on |
"turn" | "context" | "ignore" |
"turn" |
There are no global settings that affect notifications. The extension config
(ProcessConfig in extensions/processes/config/types.ts) only covers
execution, output retention, and UI — none of it changes attention or delivery.
Attention levels
An attention level is resolved per event and mapped to Pi send options by
attentionToSendOptions (extensions/processes/notification-sender.ts):
| Attention | triggerTurn |
deliverAs |
Agent effect |
|---|---|---|---|
turn |
true |
steer or followUp |
Wakes an idle agent. Generic events steer. No Block terminal events wait for the active run to settle. |
context |
false |
nextTurn |
Waits for the next user prompt; does not wake or steer the agent. |
ignore |
false |
nextTurn |
Suppressed for successful exits and external kills; emitted log matches wait for the next user prompt. Failures are promoted to context. |
Every delivered message has display: true and is persisted when Pi adds it to
the conversation. A nextTurn message is not displayed or persisted until the
next user prompt.
The delivery listener retries lifecycle messages when pi.sendMessage throws
synchronously. Delivery moves through pending, publishing, and published
states. It retries at 100 ms, 200 ms, and 400 ms. Disposal cancels pending
retries. Log-match messages do not retry because a delayed retry could bypass
their rate limit.
No Block foreground suppression
No Block initially registers each Bash job with completionDelivery: "tool".
If the job exits during the foreground wait, the Bash tool result reports the
terminal state. The notification service suppresses the duplicate lifecycle
message.
When the foreground wait expires, No Block changes the job to
completionDelivery: "notify" and sets turnDelivery: "followUp". The later
terminal event wakes the idle agent or waits for the active run to settle.
Lifecycle notifications
NotificationService.handleProcessEnded runs on every process_ended event
(deferred one microtask so the start tool can register the notify config
first). It classifies the end, resolves attention from the per-process config
or defaults, and either emits or suppresses.
Classification
classifyProcessEnd (extensions/processes/notifications/classify.ts):
ProcessInfo.status / endReason |
Kind |
|---|---|
status === "killed" |
killed |
success === true |
success |
endReason ∈ spawn_error/missing_pid/lost |
crash |
exitCode !== null && exitCode !== 0 |
crash |
| otherwise | failure |
terminate_timeout never reaches process_ended: ProcessManager.kill sets
endTime and transitions to terminate_timeout without emitting, and the
close handler bails on the endTime guard. The timeout is carried by the
tool result (KillResult.reason: "timeout") instead.
Attention resolution
resolveAttention picks the config field for the kind, falling back to
DEFAULT_ATTENTION:
| Kind | Config field | Default |
|---|---|---|
success |
onSuccess |
turn |
failure |
onFailure |
turn |
crash |
onFailure |
turn |
killed |
onKilled |
context |
Forced display for crashes/failures
After resolving attention, the service forces a minimum of context for
crash and failure:
- If attention is
ignoreand the kind iscrashorfailure, attention is upgraded tocontextand the notification is emitted. - If attention is
ignoreand the kind issuccessorkilled, the notification is suppressed entirely (no emit).
This means onFailure: "ignore" cannot silence a failed process — failures
always notify, so it becomes a context message for the next user prompt.
onSuccess: "ignore" and onKilled: "ignore" suppress their notifications.
Intentional stops (config bypass)
A stop is intentional when it goes through killIntentionally
(extensions/processes/handlers/kill-process.ts), which calls
registry.markIntentionalStop(id) before manager.kill. Three entry points
use it:
- The
process stoptool. - The
/no-block:killcommand. - The
/no-blockoverview panelxkey (viarequestKill→CHANNELS.COMMAND_KILL).
When handleProcessEnded sees an intentional stop, it bypasses the
per-process config entirely and emits a single context notification with
the classified kind. The agent is not woken, but it learns the process ended.
User onKilled/onSuccess/onFailure settings are ignored on this path.
If the kill fails (not_found/error) or the process is already dead, the
intentional-stop marker is consumed back and the normal path runs.
End-state callstacks
Each block shows the call chain and the notification it produces. "Emit" means
a CHANNELS.NOTIFICATION payload with the shown attention; the delivery
listener then sends it as a Pi message.
Normal success
child process exits 0
ProcessRuntimeController.child.on("close") src/manager/process-runtime-controller.ts
transition(managed, "exited")
emit process_ended
NotificationService.handleEvent extensions/processes/notifications/service.ts
queueMicrotask → handleProcessEnded
classifyProcessEnd → "success"
resolveAttention → config.onSuccess ?? "turn"
emit kind=success attention=<onSuccess>
delivery listener → sendProcessNotificationMessage(triggerTurn=<onSuccess>)
Default: a turn. onSuccess: "context" downgrades to a context message.
onSuccess: "ignore" suppresses.
Normal failure (non-zero exit)
child process exits non-zero
ProcessRuntimeController child.on("close")
transition(managed, "exited")
emit process_ended
handleProcessEnded
classifyProcessEnd → "crash" (exitCode !== 0)
resolveAttention → config.onFailure ?? "turn"
forced display: onFailure==="ignore" → upgraded to "context"
emit kind=crash attention=<resolved or "context">
delivery listener → sendProcessNotificationMessage
Default: a turn. onFailure: "ignore" becomes a context message (never fully
silent).
Spawn error / missing pid / lost
spawn error (or liveness tick detects lost/missing_pid)
handleSpawnError / livenessTick
transition(managed, "exited")
emit process_ended
handleProcessEnded
classifyProcessEnd → "crash"
resolveAttention → config.onFailure ?? "turn"
forced display: ignore → "context"
emit kind=crash attention=<resolved or "context">
Same as normal failure: defaults to turn, ignore forced to context.
External kill (signal from outside the manager)
external signal kills the process group
child.on("close", code, signal)
transition(managed, "killed") signal branch
emit process_ended
handleProcessEnded
isIntentionalStop? no (no marker)
classifyProcessEnd → "killed"
resolveAttention → config.onKilled ?? "context"
(no forced display for killed)
attention==="ignore" → suppressed; else emit kind=killed
delivery listener → sendProcessNotificationMessage(triggerTurn=<onKilled>)
Default: a context message (no turn). onKilled: "turn promotes to a turn.
onKilled: "ignore" suppresses.
Intentional stop (config bypass)
/no-block panel "x" | /no-block:kill | process stop tool
requestKill / executeStop
COMMAND_KILL handler extensions/processes/handlers/commands.ts
killIntentionally extensions/processes/handlers/kill-process.ts
registry.markIntentionalStop(id)
manager.kill(id, opts)
transition(managed, "killed"|"exited")
emit process_ended
handleProcessEnded
isIntentionalStop? yes
BYPASS config — emit kind=<classified> attention="context"
delivery listener → sendProcessNotificationMessage(triggerTurn=false)
Always a context message. Config is ignored. The agent is not woken.
Kill timeout (terminate_timeout)
manager.kill SIGTERM grace period expires, process still alive
ProcessRuntimeController.kill src/manager/process-runtime-controller.ts
endReason = "kill_timeout"; endTime = Date.now()
transition(managed, "terminate_timeout") (no process_ended emit — not exited/killed)
returns KillResult { ok: false, reason: "timeout" }
child.on("close") later → bails on endTime guard, no second process_ended
No notification. The timeout is reported through the tool/command result only.
Log-watch notifications
NotificationService.handleOutputChanged runs on every
process_output_changed event. It syncs the compiled matcher set for the
process, evaluates matchers against the appended text, and emits one
log_match payload per match with the matcher's on attention.
process emits output
ProcessOutput append
emit process_output_changed { id, appendedText }
NotificationService.handleOutputChanged
registry.getWatchState(id) → null? return
syncMatcherState(id, watchState)
evaluateLogMatchers(matchers, appendedText, now)
per matcher: skip if !repeat && fired; cooldown if repeat+fired
per line: stream filter, plainTextForDisplay, lineMatcher
for each match:
emit kind=log_match attention=match.on
delivery listener → rate-limit check → sendProcessNotificationMessage
Matcher behavior:
repeat: false(default) fires once per process lifetime.repeat: truefires again after a 15s cooldown (LOG_MATCH_COOLDOWN_MS).- Lines are normalized with
plainTextForDisplaybefore matching, so CR progress lines and escape bytes do not fire watches. - Lines longer than 10 000 chars are skipped.
Log-match on: "ignore" intentionally retains the match as context rather
than suppressing it. The delivery listener treats it like context, so it does
not wake or steer the agent and appears with the next user prompt. Use either
value when a match is useful later but requires no immediate response.
Log-match rate limiting
The delivery listener caps live log_match deliveries at 20 per 60s window
(MAX_LOG_MATCH_NOTIFICATIONS_PER_WINDOW, NOTIFICATION_WINDOW_MS). Overflows
are counted and flushed as a single summary at window end:
log_match payload arrives
delivery listener
window expired or new? flush prior suppressed summary (kind=log_match_suppressed, attention=context)
sentInWindow >= 20? suppressed++; return
sentInWindow++; sendProcessNotificationMessage
(window timer) flushSuppressedSummary
emit kind=log_match_suppressed summary "Suppressed N log-match notifications..."
The summary is always context.
Summary matrix
| End state | Kind | Default attention | Config field | ignore effect |
Bypassed by intentional stop? |
|---|---|---|---|---|---|
| exit 0 | success | turn | onSuccess |
suppressed | yes → context |
| exit non-zero | crash | turn | onFailure |
forced to context | yes → context |
| spawn_error/missing/lost | crash | turn | onFailure |
forced to context | yes → context |
| external signal | killed | context | onKilled |
suppressed | yes → context |
| intentional stop | (classified) | context | — | — | (this is the bypass) |
| kill timeout | — | — | — | — | no emit (tool result only) |
| log match | log_match | turn | matcher on |
delivered as context | n/a |
| log-match overflow | log_match_suppressed | context | — | — | n/a |
References
extensions/processes/notifications/service.ts— event handling, attention resolution, intentional-stop bypass, log-match emission.extensions/processes/notifications/classify.ts— end classification.extensions/processes/notifications/registry.ts— per-process config and intentional-stop markers.extensions/processes/handlers/kill-process.ts— intentional stop marking.extensions/processes/handlers/notifications.ts— delivery listener and rate limiting.extensions/processes/notification-sender.ts— attention → Pi send options.extensions/processes/tools/notify.ts— config normalization and defaults.src/manager/process-runtime-controller.ts—process_endedemission,terminate_timeoutnon-emit,close/error/liveness paths.