Skip to content

feat: Event-driven RunPane orchestration #356

Description

@parsakhaz

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.
  • Auto-approving irreversible actions (merge, release, deploy, publish).
  • 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).
  • D2panels 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 2 M [ ]
5 Milestones, warnings, incremental output panels events emit, warning severity separation, panels output --since 2 M [ ]

Phase 1 — Semantic state model

  • 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 [ ]
    AC3 automated same test parameterized over debounce config [ ]
    AC4 automated integration test killing the PTY [ ]

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 + 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 [ ]

Phase 3 — Multi-target supervision + reconciliation

  • 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 [ ]

Phase 5 — Milestones, warnings, incremental output

  • 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.
  • Docsdocs/RUNPANE_CLI_CONTRACT.md gains copy-paste examples with representative
    JSON/JSONL for each new command as its phase lands.
  • Dogfood metric — after phase 3, measure monitoring calls and bytes returned per
    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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions