You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
type: epic-spec
id: runpane-event-orchestration
status: ready
zone: 1
pr: one PR for the whole epic — phases commit sequentially
github: #356
Epic: Event-driven RunPane orchestration
Problem / context
A long-lived "Pane Chat" orchestrator supervising background coding agents has no way to
sleep until something meaningful happens. It polls panels screen / short-timeout panels wait, re-reads unchanged terminal output, and infers transitions itself — burning
tool calls, context tokens, and wall-clock while risking missed blockers between polls. panels wait --for ready returns while the agent is still actively working because isCliReady latches permanently after the CLI's first response, independent of activity.
Research established the foundation is closer than the original issue (#353) assumed: the
daemon socket protocol already broadcasts event frames to all connected clients
(main/src/daemon/server.ts:66) — current CLI clients just discard them
(packages/runpane/src/daemonClient.ts:134). What's missing is semantic state in the main
process, durable event persistence with cursors, and CLI subscription surfaces.
Supersedes GitHub issue #353 (close with cross-link on publish). The two quick wins peeled
from #353 — declarative pane pinning and slash-command-aware prompt submission — are
separate Feature Tickets with their own PRs, not phases of this epic.
Goals
An orchestrator can block on one command until a panel becomes idle, requires input,
becomes blocked, or exits — without screen text in the payload and without polling.
Multiple panels/panes are supervisable through a single watch, including panels created
after the watch starts.
A disconnected client resumes from a cursor without silently losing transitions.
Pane/agent creation can verify the initial prompt was actually submitted and work began.
Routine supervision payloads are compact enough that dozens of panels are practical.
Everything the event surface can observe is also pull-readable via request/response
commands — polling stays a fully supported fallback (D8).
Non-goals
Encoding plan/implement/review/merge policy in RunPane — higher-level skills interpret
events.
Replacing terminal inspection — panels screen/output remain for deliberate use.
Semantic understanding of arbitrary terminal apps without adapter support.
A remote hosted event service — local daemon/IPC delivery only.
Per-agent screen-aware idle detection as a base requirement (see D1).
Key architecture decisions (cross-cutting)
D1 — Agent activity (active/idle) is derived from a byte-silence debounce,
longer and configurable vs today's fixed 30s — it generalizes to any agent with no
per-agent adapters, keeping the base event contract agent-agnostic · rejected:
screen-aware per-agent adapters as the base signal (privileges Claude/Codex; may later refine the signal as an optional layer but must never be load-bearing for base
events). Empirically validated (2026-07-21, refs/idle-probe-evidence.md): both
Claude Code 2.1.217 and Codex 0.144.4 emit zero PTY bytes for 150s+ at the idle
composer, and emit continuously while working — the repaint-while-idle failure mode
did not occur. Remaining accepted limitation: silent thinking past the debounce reads
as a false idle (mitigated by configurable debounce + D9 reconciliation).
D2 — panels watch (JSONL subscription over the existing daemon event broadcast)
is the core primitive; panels await / await-any are exit-on-first-match sugar; panels wait is frozen as the legacy polling surface — its conditions are polling
predicates, semantically different from event waits · rejected: extending panels wait (conflates the two models); a new orchestrate command group
(reconciliation is just panes status --changed-since).
D3 — One global daemon-scoped monotonic cursor over a bounded in-memory event
ring; reconnect recovery is snapshot-then-subscribe (panes status --changed-since
returns a race-free cursor); lifecycle-event and output cursors are separate
namespaces. An app restart kills every PTY, so pre-restart lifecycle events describe
dead processes — the correct recovery is a fresh snapshot, not replay; a pre-restart
or over-aged cursor yields a structured cursor_expired pointing at snapshot
reconciliation. A durable SQLite table is introduced only in phase 5, where milestones
(the one genuinely replay-shaped payload) land · rejected: SQLite-persisted lifecycle
log upfront (schema migration + retention pruning for replay no orchestrator decision
needs); per-pane cursor scopes (complexity, no gain).
D4 — Semantic transitions are computed in the main process and become the
orchestrator-facing canonical attention layer — no reusable source exists today:
unread state and notification debounce live renderer-only
(frontend/src/hooks/useNotifications.ts), unreachable from the daemon. Two
attention truths coexist during this epic (renderer human-facing, main-process
orchestrator-facing); that is accepted: the smarter semantic layer is the safe default
the orchestrator consumes, with the dumb reliable signal (raw activityStatus +
pull polling) as the fallback. Migrating renderer notifications onto the canonical
layer is a named follow-up ticket, not part of this epic (see Dependencies) ·
rejected: reusing renderer notification state as feat: add event-driven RunPane orchestration and reliable agent startup #353 scope item 6 proposed (not
implementable as written).
D5 — Agent-emitted milestones are namespaced, schema-versioned, and trust-labeled
claims (producer preserved, daemon-derived vs agent-emitted distinguished); they never
grant authority for merge/deploy/release/publish or other hard-stop actions.
D6 — Unknown or consequential interstitials (auth, permissions, destructive
confirmations, terms, payments) are never auto-answered; automatic handling covers only
an explicit allowlist of reversible responses under caller opt-in, and everything else
emits input_required with structured blocker data.
D7 — All changes are additive: existing commands and JSON contracts keep working,
npm CLI / daemon schema / generated contracts / Python wrapper stay in parity
(scripts/test-runpane-contract.js), machine-readable output stays stdout-clean.
D8 — Pull remains first-class, not a legacy compatibility shim: every semantic
state and transition observable via watch/await must also be readable through a
request/response command (panels screen/wait carry the phase-1 state block; panes status --changed-since is the canonical pull reconciliation) — an orchestrator
must always be able to poll when it prefers pull or the event path is unavailable ·
rejected: event subscription as the sole observation path (a broken/unavailable stream
would leave supervision blind).
D9 — Long-lived waits self-check: an await/watch that has received no events
for a configurable heartbeat interval (default on the order of minutes, well under the
timeout) internally pulls current state and reconciles it against the event log, so a
missed event, wedged stream, or dead panel resolves the wait or surfaces a structured
error instead of silently hanging to timeout — push paths fail quietly; heartbeat
reconciliation is the standard supervisor pattern (Claude Code's monitors do the
same) · rejected: trusting push delivery alone until timeout expiry (turns a dropped
event into a full-timeout stall).
Phases (sequential)
#
Phase
Desired end state
Depends on
Size
✓
1
Semantic state model
Orthogonal terminalReady/agentActivity/inputRequired/blocked state with configurable debounce, exposed in existing responses
—
M
[ ]
2
Event log, cursors, single-panel watch/await
Durable event log; panels watch --jsonl --since and panels await --event work for one panel
1
L
[ ]
3
Multi-target supervision + reconciliation
await-any across panels, panes watch --include-future-panels, panes status --changed-since
2
M
[ ]
4
Verified startup + safe interstitials
panes create high-level mode completes only on verified submission + activity begun
Scope: Define the orthogonal state fields (terminalReady, agentActivity with unknown|starting|active|idle|exited, inputRequired, blocked, hasNewOutput, lastActivity, lastMeaningfulEventAt) computed in the main process; make the
idle debounce configurable and longer than the current 30s default; surface the new
state block in existing panels screen/wait JSON responses additively.
Out of scope: event persistence, new CLI commands, renderer notification migration.
Proposed approach: Activity tracking already lives in main/src/services/terminalPanelManager.ts (~1105–1117: first-output→active,
30s-silence→idle) and emits panel:activityStatus; state assembly for RunPane responses
lives in main/src/ipc/runpane.ts (~852–1016), including the bounded-screen blocker
scan that blocked/inputRequired can reuse. isCliReady latching
(terminalPanelManager.ts:~981) stays as-is and feeds terminalReady.
Verification:
AC1 — WHEN a CLI panel has produced its first CLI response and is still emitting
PTY output, panels screen --json shall report terminalReady: true and agentActivity: "active" as distinct fields in the same response.
AC2 — WHEN a panel emits no PTY output for the configured debounce interval,
the state shall transition to agentActivity: "idle" and record lastMeaningfulEventAt.
AC3 — WHEN the idle debounce is configured to N seconds, the active→idle
transition shall occur after N seconds of silence, not the previous fixed 30.
AC4 — WHEN a panel's process exits, the state shall report agentActivity: "exited".
Criterion
Method
Command / flow
✓
AC1
automated
main-process integration test (pattern: main/src/ipc/runpane.test.ts)
[ ]
AC2
automated
integration test with fake PTY silence + short debounce
Scope: Bounded in-memory event ring (D3) recording semantic transitions
(panel_created, terminal_ready, prompt_staged, prompt_submitted, agent_active, agent_idle, input_required, blocked, unblocked, panel_exited, panel_archived); global monotonic cursor with bounded retention; panels watch --panel --event --since --jsonl (long-lived subscription client in npm + Python
wrappers); panels await --panel --event --timeout-ms sugar; exclusive --since,
stable event IDs, at-least-once delivery, structured cursor_expired error naming the
earliest available cursor; heartbeat reconciliation inside await/watch per D9
(configurable interval, pull-and-reconcile when no events arrive); timeout returns a
result, not a bare failure — timedOut: true plus the current reconciled state — and
exit codes documented to distinguish matched / timed-out / transport-error.
Out of scope: multi-panel/pane targets, milestone emission, output cursors,
durable event storage (phase 5, with milestones).
Proposed approach: The daemon already broadcasts event frames to all socket clients
(main/src/daemon/server.ts:66, sinks at main/src/daemon/bootstrap.ts:245); the gap
is client-side — both wrappers close after one matching response
(packages/runpane/src/daemonClient.ts:107, packages/runpane-py/src/runpane/daemon_client.py:62) — plus sequencing: event frames
carry no sequence numbers today. Stamp transitions from phase 1's state machine with
the global cursor into a bounded in-memory ring, and serve both bounded replay
(--since) and live tail over the existing channel.
Verification:
AC1 — WHEN panels await --event agent-idle is issued against a panel where terminalReady is true and agentActivity is active, the command shall remain
blocked until an idle/input-required/blocked/exited transition occurs (regression for
the observed ready-while-active early return).
AC2 — WHEN a watched panel undergoes a semantic transition, panels watch --jsonl
shall emit exactly one JSON record containing event ID, cursor, type, timestamp,
paneId, panelId, and compact state — with no screen.text field.
AC3 — WHILE a watched panel's state is unchanged, panels watch shall emit zero
records for that panel.
AC4 — WHEN a client reconnects with --since <cursor>, the stream shall replay
all retained events strictly after that cursor in order, and re-delivered duplicates
shall be identifiable by identical event IDs.
AC5 — IF --since names a cursor older than retention, THEN the command shall
exit with a structured cursor_expired error containing the earliest available
cursor.
AC6 — WHEN a client presents a pre-restart cursor after the Pane app restarts,
the response shall be a structured cursor_expired error naming the earliest
available cursor and a snapshot reconciliation command — never a silent empty
stream.
AC7 — IF a semantic transition occurs but its event is not delivered to an
active await/watch (simulated stream stall), THEN the command shall detect the
satisfied condition via heartbeat reconciliation within one heartbeat interval and
resolve with the reconciled state, marked as reconciled rather than event-delivered
(D9).
AC8 — WHEN an await reaches its timeout without a matching event, the result
shall contain timedOut: true plus the panel's current reconciled state, and the
process shall exit with the documented timed-out exit code, distinct from the
matched and transport-error codes.
Criterion
Method
Command / flow
✓
AC1
automated
integration test: latched isCliReady + active output + await
[ ]
AC2, AC3
automated
watch subscription test over fake transitions
[ ]
AC4, AC5
automated
replay/dedup/expiry tests against the event ring
[ ]
AC6
automated
stale-cursor test across daemon re-bootstrap
[ ]
AC7
automated
await with suppressed event delivery + short heartbeat
[ ]
AC8
automated
await with short timeout; assert payload + exit code
[ ]
AC1–AC8 parity
automated
scripts/test-runpane-contract.js extended for new commands
Scope:panels await-any accepting repeated --panel/--pane; panes watch --pane --include-future-panels; panes status --pane --changed-since compact snapshot
returning per-panel semantic state plus a snapshot cursor that is race-free against a
subsequent --since subscription.
Out of scope: cross-pane aggregation policies; any UI changes.
Proposed approach: Filtering layer over phase 2's event stream — targets resolve to
panel sets, panel_created events extend the set when --include-future-panels is on.
Snapshot reads current state + max cursor atomically enough that
snapshot-then-subscribe loses nothing.
Verification:
AC1 — WHEN any one of N awaited panels transitions to a requested event, the
result shall identify that panel's panelId and paneId.
AC2 — WHEN a new panel is created inside a watched pane with --include-future-panels, the stream shall emit its panel_created event and
subsequent transitions without restarting the watch.
AC3 — WHEN panes status --changed-since <cursor> is issued, the response shall
list only panels with transitions after the cursor and include a new cursor such
that a watch started from it observes no gap and no duplicate beyond at-least-once
semantics.
Criterion
Method
Command / flow
✓
AC1–AC3
automated
multi-panel integration tests + contract parity run
[ ]
Phase 4 — Verified startup + safe interstitials
Dependency gate: benefits from the standalone "slash-command-aware prompt
submission" Feature Ticket landing first — this phase builds on its verified submit-composer semantics for /-prefixed prompts.
Scope: High-level creation/start mode (panes create ... --start-agent --wait-active) that stages the initial prompt, submits via the semantic composer path,
reports success only after verifiedSubmitted: true and agent_active; optional --handle-known-interstitials safe executing only allowlisted reversible responses
(e.g. skip an optional update prompt) then continuing the original prompt exactly once;
idempotent retry after transport failure without duplicating the prompt.
Regression fixtures include three interstitials reproduced on 2026-07-21: the Codex
update prompt intercepting initial startup (allowlist candidate), a
planning-suggestion modal swallowing normal prompt submission (recovered in the
field by submit-composer --strategy auto), and the Codex directory-trust prompt,
which killed a session when blind text+Enter submission hit it
(refs/idle-probe-evidence.md) — trust prompts are consequential and must emit input_required, never be auto-answered (D6).
Out of scope: auto-answering anything beyond the allowlist (D6); interstitial
handling for arbitrary third-party TUIs.
Proposed approach: Compose existing primitives — submit-composer verification
(main/src/ipc/runpane.ts:~1128: active-transition or composer-prompt-disappearance)
plus phase 2's prompt_submitted/agent_active events — into one daemon-side flow so
the caller gets a single structured result. Interstitial detection extends the existing
bounded-screen blocker scan with an explicit allowlist registry.
Verification:
AC1 — WHEN the high-level start completes with ok: true, the response shall
include verifiedSubmitted: true and agentActivity: "active".
AC2 — IF prompt text remains visible in the composer after bounded submit
retries, THEN the command shall return ok: false with a submission_unverified
blocker and attempt count.
AC3 — WHEN an allowlisted interstitial is detected under --handle-known-interstitials safe, the response shall list the handled
interstitial and the original prompt shall be submitted exactly once thereafter.
AC4 — IF a non-allowlisted interstitial is detected, THEN the flow shall stop and
emit input_required with structured blocker data, regardless of the
interstitial-handling flag.
Criterion
Method
Command / flow
✓
AC1, AC2
automated
integration tests over scripted composer states
[ ]
AC3, AC4
automated
fixture interstitials (allowlisted + unknown) driven through the flow
Scope:panels events emit --type <ns>.<name> --data-file producing durable
namespaced milestone events labeled with producer and trust status (D5), backed by
the epic's one SQLite table + migration (durable storage arrives here per D3 —
milestones are the replay-shaped payload that justifies it); warning
events (e.g. mcp_authentication) carrying severity and observed impact separately,
deduplicated while unchanged, subscribable independently of blocking events; panels output --panel --since <output-cursor> --limit for incremental agent output.
Out of scope: inferring high-level milestones from terminal text; any authority
attached to milestones.
Proposed approach: Milestones reuse phase 2's event table with a producer /
namespace envelope. Warnings extend the blocker-scan classification with a severity
axis — warning text alone never produces blocked (behavioral evidence or explicit
agent request required). Output cursoring can seed from session_outputs.id
(main/src/database/schema.sql:33) — but raw scrollback is not 1:1 with session_outputs rows, so the phase must first decide which stream --since reads
and document it (open question below).
Verification:
AC1 — WHEN an agent emits a milestone via events emit, subscribers shall
receive an event carrying its namespace, schema version, producer, and an explicit
agent-emitted (unverified) trust marker.
AC2 — WHILE terminal text contains an authentication warning and the agent
continues producing output, the state shall report the warning as blocking: false
and shall not emit a blocked event.
AC3 — WHEN the same warning persists unchanged, no additional warning event
shall be emitted.
AC4 — WHEN panels output --since <cursor> is issued, the response shall
contain only output recorded after that cursor.
Criterion
Method
Command / flow
✓
AC1
automated
emit + subscribe round-trip test
[ ]
AC2, AC3
automated
fixture warning screens + activity simulation
[ ]
AC4
automated
output cursor test over seeded rows
[ ]
Cross-cutting concerns
Contract parity — every phase extends scripts/test-runpane-contract.js and the
generated contracts; npm and Python wrappers ship the same surface (D7).
Migration safety — the epic's single schema migration (phase 5's milestone table)
follows the existing dual migration system; retention pruning must not grow the DB
unbounded.
Security — D5 (milestones are unverified claims, never authority) and D6
(interstitial allowlist) are review checkpoints in every affected phase.
Docs — docs/RUNPANE_CLI_CONTRACT.md gains copy-paste examples with representative
JSON/JSONL for each new command as its phase lands.
Named follow-up ticket (captured after this epic, not part of it): migrate renderer
unread/notification logic onto the main-process canonical attention layer — until
then the two attention truths coexist per D4.
Justification
Byte-silence idle detection is unvalidated — held: PTY probes (claude 2.1.217,
codex 0.144.4) show 0 bytes over 150s idle windows and continuous emission while
active; residual false-idle risk mitigated by configurable debounce + D9
(refs/idle-probe-evidence.md).
A durable SQLite event log is over-built for lifecycle replay — held by concession:
replaced with bounded in-memory ring + snapshot-then-subscribe; durable table
deferred to phase 5 where milestones justify it.
Phases 4–5 may be speculative — held: three phase-4 interstitial failures reproduced
in the field on 2026-07-21 and captured as regression fixtures; phase 5's consumer is
the runpane-orchestrator skills; post-phase-3 dogfood metric retained as a non-gating
checkpoint.
Two attention truths would drift — held as accepted cost: semantic layer is the
orchestrator-facing canonical default, raw signal is the fallback, and renderer
migration is a named follow-up ticket in Dependencies.
Open questions
[NEEDS CLARIFICATION] Which stream panels output --since reads — session_outputs rows or serialized scrollback — resolve before phase 5 is picked up.
[NEEDS CLARIFICATION] Default value for the idle debounce (and its config surface) —
resolve during phase 1 planning.
References (in refs/)
refs/discussion.md — decision log from the 2026-07-21 discussion (research findings
with file:line evidence, decisions D1–D4 provenance).
refs/idle-probe-evidence.md — PTY probe measurements validating D1 (byte-silence at
idle composer for Claude Code and Codex), plus incidental interstitial/warning
evidence for phases 4–5.
type: epic-spec
id: runpane-event-orchestration
status: ready
zone: 1
pr: one PR for the whole epic — phases commit sequentially
github: #356
Epic: Event-driven RunPane orchestration
Problem / context
A long-lived "Pane Chat" orchestrator supervising background coding agents has no way to
sleep until something meaningful happens. It polls
panels screen/ short-timeoutpanels wait, re-reads unchanged terminal output, and infers transitions itself — burningtool calls, context tokens, and wall-clock while risking missed blockers between polls.
panels wait --for readyreturns while the agent is still actively working becauseisCliReadylatches permanently after the CLI's first response, independent of activity.Research established the foundation is closer than the original issue (#353) assumed: the
daemon socket protocol already broadcasts event frames to all connected clients
(
main/src/daemon/server.ts:66) — current CLI clients just discard them(
packages/runpane/src/daemonClient.ts:134). What's missing is semantic state in the mainprocess, durable event persistence with cursors, and CLI subscription surfaces.
Supersedes GitHub issue #353 (close with cross-link on publish). The two quick wins peeled
from #353 — declarative pane pinning and slash-command-aware prompt submission — are
separate Feature Tickets with their own PRs, not phases of this epic.
Goals
becomes blocked, or exits — without screen text in the payload and without polling.
after the watch starts.
commands — polling stays a fully supported fallback (D8).
Non-goals
events.
panels screen/outputremain for deliberate use.Key architecture decisions (cross-cutting)
active/idle) is derived from a byte-silence debounce,longer and configurable vs today's fixed 30s — it generalizes to any agent with no
per-agent adapters, keeping the base event contract agent-agnostic · rejected:
screen-aware per-agent adapters as the base signal (privileges Claude/Codex; may later
refine the signal as an optional layer but must never be load-bearing for base
events). Empirically validated (2026-07-21,
refs/idle-probe-evidence.md): bothClaude Code 2.1.217 and Codex 0.144.4 emit zero PTY bytes for 150s+ at the idle
composer, and emit continuously while working — the repaint-while-idle failure mode
did not occur. Remaining accepted limitation: silent thinking past the debounce reads
as a false idle (mitigated by configurable debounce + D9 reconciliation).
panels watch(JSONL subscription over the existing daemon event broadcast)is the core primitive;
panels await/await-anyare exit-on-first-match sugar;panels waitis frozen as the legacy polling surface — its conditions are pollingpredicates, semantically different from event waits · rejected: extending
panels wait(conflates the two models); a neworchestratecommand group(reconciliation is just
panes status --changed-since).ring; reconnect recovery is snapshot-then-subscribe (
panes status --changed-sincereturns a race-free cursor); lifecycle-event and output cursors are separate
namespaces. An app restart kills every PTY, so pre-restart lifecycle events describe
dead processes — the correct recovery is a fresh snapshot, not replay; a pre-restart
or over-aged cursor yields a structured
cursor_expiredpointing at snapshotreconciliation. A durable SQLite table is introduced only in phase 5, where milestones
(the one genuinely replay-shaped payload) land · rejected: SQLite-persisted lifecycle
log upfront (schema migration + retention pruning for replay no orchestrator decision
needs); per-pane cursor scopes (complexity, no gain).
orchestrator-facing canonical attention layer — no reusable source exists today:
unread state and notification debounce live renderer-only
(
frontend/src/hooks/useNotifications.ts), unreachable from the daemon. Twoattention truths coexist during this epic (renderer human-facing, main-process
orchestrator-facing); that is accepted: the smarter semantic layer is the safe default
the orchestrator consumes, with the dumb reliable signal (raw
activityStatus+pull polling) as the fallback. Migrating renderer notifications onto the canonical
layer is a named follow-up ticket, not part of this epic (see Dependencies) ·
rejected: reusing renderer notification state as feat: add event-driven RunPane orchestration and reliable agent startup #353 scope item 6 proposed (not
implementable as written).
claims (producer preserved, daemon-derived vs agent-emitted distinguished); they never
grant authority for merge/deploy/release/publish or other hard-stop actions.
confirmations, terms, payments) are never auto-answered; automatic handling covers only
an explicit allowlist of reversible responses under caller opt-in, and everything else
emits
input_requiredwith structured blocker data.npm CLI / daemon schema / generated contracts / Python wrapper stay in parity
(
scripts/test-runpane-contract.js), machine-readable output stays stdout-clean.state and transition observable via
watch/awaitmust also be readable through arequest/response command (
panels screen/waitcarry the phase-1 state block;panes status --changed-sinceis the canonical pull reconciliation) — an orchestratormust always be able to poll when it prefers pull or the event path is unavailable ·
rejected: event subscription as the sole observation path (a broken/unavailable stream
would leave supervision blind).
await/watchthat has received no eventsfor a configurable heartbeat interval (default on the order of minutes, well under the
timeout) internally pulls current state and reconciles it against the event log, so a
missed event, wedged stream, or dead panel resolves the wait or surfaces a structured
error instead of silently hanging to timeout — push paths fail quietly; heartbeat
reconciliation is the standard supervisor pattern (Claude Code's monitors do the
same) · rejected: trusting push delivery alone until timeout expiry (turns a dropped
event into a full-timeout stall).
Phases (sequential)
terminalReady/agentActivity/inputRequired/blockedstate with configurable debounce, exposed in existing responsespanels watch --jsonl --sinceandpanels await --eventwork for one panelawait-anyacross panels,panes watch --include-future-panels,panes status --changed-sincepanes createhigh-level mode completes only on verified submission + activity begunpanels events emit, warning severity separation,panels output --sincePhase 1 — Semantic state model
Scope: Define the orthogonal state fields (
terminalReady,agentActivitywithunknown|starting|active|idle|exited,inputRequired,blocked,hasNewOutput,lastActivity,lastMeaningfulEventAt) computed in the main process; make theidle debounce configurable and longer than the current 30s default; surface the new
state block in existing
panels screen/waitJSON responses additively.Out of scope: event persistence, new CLI commands, renderer notification migration.
Proposed approach: Activity tracking already lives in
main/src/services/terminalPanelManager.ts(~1105–1117: first-output→active,30s-silence→idle) and emits
panel:activityStatus; state assembly for RunPane responseslives in
main/src/ipc/runpane.ts(~852–1016), including the bounded-screen blockerscan that
blocked/inputRequiredcan reuse.isCliReadylatching(
terminalPanelManager.ts:~981) stays as-is and feedsterminalReady.Verification:
PTY output,
panels screen --jsonshall reportterminalReady: trueandagentActivity: "active"as distinct fields in the same response.the state shall transition to
agentActivity: "idle"and recordlastMeaningfulEventAt.transition shall occur after N seconds of silence, not the previous fixed 30.
agentActivity: "exited".main/src/ipc/runpane.test.ts)Phase 2 — Event log, cursors, single-panel watch/await
Scope: Bounded in-memory event ring (D3) recording semantic transitions
(
panel_created,terminal_ready,prompt_staged,prompt_submitted,agent_active,agent_idle,input_required,blocked,unblocked,panel_exited,panel_archived); global monotonic cursor with bounded retention;panels watch --panel --event --since --jsonl(long-lived subscription client in npm + Pythonwrappers);
panels await --panel --event --timeout-mssugar; exclusive--since,stable event IDs, at-least-once delivery, structured
cursor_expirederror naming theearliest available cursor; heartbeat reconciliation inside
await/watchper D9(configurable interval, pull-and-reconcile when no events arrive); timeout returns a
result, not a bare failure —
timedOut: trueplus the current reconciled state — andexit codes documented to distinguish matched / timed-out / transport-error.
Out of scope: multi-panel/pane targets, milestone emission, output cursors,
durable event storage (phase 5, with milestones).
Proposed approach: The daemon already broadcasts event frames to all socket clients
(
main/src/daemon/server.ts:66, sinks atmain/src/daemon/bootstrap.ts:245); the gapis client-side — both wrappers close after one matching response
(
packages/runpane/src/daemonClient.ts:107,packages/runpane-py/src/runpane/daemon_client.py:62) — plus sequencing: event framescarry no sequence numbers today. Stamp transitions from phase 1's state machine with
the global cursor into a bounded in-memory ring, and serve both bounded replay
(
--since) and live tail over the existing channel.Verification:
panels await --event agent-idleis issued against a panel whereterminalReadyis true andagentActivityis active, the command shall remainblocked until an idle/input-required/blocked/exited transition occurs (regression for
the observed
ready-while-activeearly return).panels watch --jsonlshall emit exactly one JSON record containing event ID, cursor, type, timestamp,
paneId, panelId, and compact state — with no
screen.textfield.panels watchshall emit zerorecords for that panel.
--since <cursor>, the stream shall replayall retained events strictly after that cursor in order, and re-delivered duplicates
shall be identifiable by identical event IDs.
--sincenames a cursor older than retention, THEN the command shallexit with a structured
cursor_expirederror containing the earliest availablecursor.
the response shall be a structured
cursor_expirederror naming the earliestavailable cursor and a snapshot reconciliation command — never a silent empty
stream.
active
await/watch(simulated stream stall), THEN the command shall detect thesatisfied condition via heartbeat reconciliation within one heartbeat interval and
resolve with the reconciled state, marked as reconciled rather than event-delivered
(D9).
awaitreaches its timeout without a matching event, the resultshall contain
timedOut: trueplus the panel's current reconciled state, and theprocess shall exit with the documented timed-out exit code, distinct from the
matched and transport-error codes.
isCliReady+ active output + awaitscripts/test-runpane-contract.jsextended for new commandsPhase 3 — Multi-target supervision + reconciliation
Scope:
panels await-anyaccepting repeated--panel/--pane;panes watch --pane --include-future-panels;panes status --pane --changed-sincecompact snapshotreturning per-panel semantic state plus a snapshot cursor that is race-free against a
subsequent
--sincesubscription.Out of scope: cross-pane aggregation policies; any UI changes.
Proposed approach: Filtering layer over phase 2's event stream — targets resolve to
panel sets,
panel_createdevents extend the set when--include-future-panelsis on.Snapshot reads current state + max cursor atomically enough that
snapshot-then-subscribe loses nothing.
Verification:
result shall identify that panel's panelId and paneId.
--include-future-panels, the stream shall emit itspanel_createdevent andsubsequent transitions without restarting the watch.
panes status --changed-since <cursor>is issued, the response shalllist only panels with transitions after the cursor and include a new cursor such
that a watch started from it observes no gap and no duplicate beyond at-least-once
semantics.
Phase 4 — Verified startup + safe interstitials
Scope: High-level creation/start mode (
panes create ... --start-agent --wait-active) that stages the initial prompt, submits via the semantic composer path,reports success only after
verifiedSubmitted: trueandagent_active; optional--handle-known-interstitials safeexecuting only allowlisted reversible responses(e.g. skip an optional update prompt) then continuing the original prompt exactly once;
idempotent retry after transport failure without duplicating the prompt.
Regression fixtures include three interstitials reproduced on 2026-07-21: the Codex
update prompt intercepting initial startup (allowlist candidate), a
planning-suggestion modal swallowing normal prompt submission (recovered in the
field by
submit-composer --strategy auto), and the Codex directory-trust prompt,which killed a session when blind text+Enter submission hit it
(
refs/idle-probe-evidence.md) — trust prompts are consequential and must emitinput_required, never be auto-answered (D6).Out of scope: auto-answering anything beyond the allowlist (D6); interstitial
handling for arbitrary third-party TUIs.
Proposed approach: Compose existing primitives —
submit-composerverification(
main/src/ipc/runpane.ts:~1128: active-transition or composer-prompt-disappearance)plus phase 2's
prompt_submitted/agent_activeevents — into one daemon-side flow sothe caller gets a single structured result. Interstitial detection extends the existing
bounded-screen blocker scan with an explicit allowlist registry.
Verification:
ok: true, the response shallinclude
verifiedSubmitted: trueandagentActivity: "active".retries, THEN the command shall return
ok: falsewith asubmission_unverifiedblocker and attempt count.
--handle-known-interstitials safe, the response shall list the handledinterstitial and the original prompt shall be submitted exactly once thereafter.
emit
input_requiredwith structured blocker data, regardless of theinterstitial-handling flag.
Phase 5 — Milestones, warnings, incremental output
Scope:
panels events emit --type <ns>.<name> --data-fileproducing durablenamespaced milestone events labeled with producer and trust status (D5), backed by
the epic's one SQLite table + migration (durable storage arrives here per D3 —
milestones are the replay-shaped payload that justifies it); warning
events (e.g.
mcp_authentication) carrying severity and observed impact separately,deduplicated while unchanged, subscribable independently of blocking events;
panels output --panel --since <output-cursor> --limitfor incremental agent output.Out of scope: inferring high-level milestones from terminal text; any authority
attached to milestones.
Proposed approach: Milestones reuse phase 2's event table with a
producer/namespace envelope. Warnings extend the blocker-scan classification with a severity
axis — warning text alone never produces
blocked(behavioral evidence or explicitagent request required). Output cursoring can seed from
session_outputs.id(
main/src/database/schema.sql:33) — but raw scrollback is not 1:1 withsession_outputsrows, so the phase must first decide which stream--sincereadsand document it (open question below).
Verification:
events emit, subscribers shallreceive an event carrying its namespace, schema version, producer, and an explicit
agent-emitted (unverified) trust marker.
continues producing output, the state shall report the warning as
blocking: falseand shall not emit a
blockedevent.shall be emitted.
panels output --since <cursor>is issued, the response shallcontain only output recorded after that cursor.
Cross-cutting concerns
scripts/test-runpane-contract.jsand thegenerated contracts; npm and Python wrappers ship the same surface (D7).
follows the existing dual migration system; retention pruning must not grow the DB
unbounded.
(interstitial allowlist) are review checkpoints in every affected phase.
docs/RUNPANE_CLI_CONTRACT.mdgains copy-paste examples with representativeJSON/JSONL for each new command as its phase lands.
agent-hour in a real Pane Chat workstream vs the polling baseline from feat: add event-driven RunPane orchestration and reliable agent startup #353.
Dependencies
pinning (feat: Declarative pane pinning via RunPane CLI #357) and reliable slash-command initial prompt delivery (feat: Reliable slash-command initial prompt delivery #358).
Neither blocks phases 1–3; phase 4 prefers feat: Reliable slash-command initial prompt delivery #358 landed first (see its dependency
gate).
unread/notification logic onto the main-process canonical attention layer — until
then the two attention truths coexist per D4.
Justification
codex 0.144.4) show 0 bytes over 150s idle windows and continuous emission while
active; residual false-idle risk mitigated by configurable debounce + D9
(
refs/idle-probe-evidence.md).replaced with bounded in-memory ring + snapshot-then-subscribe; durable table
deferred to phase 5 where milestones justify it.
in the field on 2026-07-21 and captured as regression fixtures; phase 5's consumer is
the runpane-orchestrator skills; post-phase-3 dogfood metric retained as a non-gating
checkpoint.
orchestrator-facing canonical default, raw signal is the fallback, and renderer
migration is a named follow-up ticket in Dependencies.
Open questions
[NEEDS CLARIFICATION]Which streampanels output --sincereads —session_outputsrows or serialized scrollback — resolve before phase 5 is picked up.[NEEDS CLARIFICATION]Default value for the idle debounce (and its config surface) —resolve during phase 1 planning.
References (in refs/)
refs/discussion.md— decision log from the 2026-07-21 discussion (research findingswith file:line evidence, decisions D1–D4 provenance).
refs/idle-probe-evidence.md— PTY probe measurements validating D1 (byte-silence atidle composer for Claude Code and Codex), plus incidental interstitial/warning
evidence for phases 4–5.