chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:05:33 +08:00
commit e25d789156
8165 changed files with 2004905 additions and 0 deletions
@@ -0,0 +1,193 @@
# Design: Manual network address entry for Orca desktop mobile pairing
**Date:** 2026-06-27
**Scope:** Desktop renderer (Settings → Mobile → Network Interface section)
**Status:** Draft, awaiting user review
## Problem
`src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` lets the user pick the network address that gets baked into the mobile-pairing QR code. Today the only options come from `networkInterfaces`, which is the list returned by the main process enumerating OS network interfaces (`en0`, `tailscale0`, etc.). If a user wants a tailnet address that the OS hasn't surfaced yet — a Tailscale MagicDNS hostname, a ZeroTier-assigned address not yet visible to the OS, or a manual LAN IP — they have no way to type one in. The QR ends up pointing at an interface the phone cannot actually reach.
## Decision summary
Replace the inner `Select` of `MobileNetworkInterfaceSection` with a `Popover + Command` ("combobox") pattern modeled on the existing `AgentCombobox`. The popover contains a single `CommandInput` that filters the auto-discovered interfaces above and renders a special "Use …" entry at the bottom of the list whenever the input is a valid IPv4 address or Tailscale MagicDNS hostname. Picking that entry selects a custom address; the trigger shows `<address> (custom)`. Custom addresses are session-scoped (cleared when the settings pane closes).
## Constraints (from `CONTRIBUTING.md` + `AGENTS.md`)
- Cross-platform: code paths must not assume a single platform; the manual entry path itself is platform-neutral.
- No `helpers`/`utils`/`misc` file names; use concrete names.
- No `eslint-disable max-lines`; split files instead.
- Prefer `.ts` over `.d.ts`.
- UI work follows `docs/STYLEGUIDE.md` and uses shadcn primitives from `src/renderer/src/components/ui/`.
- The renderer ↔ shared boundary is `src/shared/`; pure logic that may be reused outside the renderer goes there.
- Comments explain *why*, briefly.
## Files
| Path | Change |
| --- | --- |
| `src/shared/network/manual-address.ts` | **New.** Pure `parseManualNetworkAddress(input)` returning a discriminated union. |
| `src/shared/network/manual-address.test.ts` | **New.** Vitest cases for IPv4 and MagicDNS hostname validation. |
| `src/renderer/src/components/settings/mobile-network-interface-selection.ts` | Replace `mergeForSelect` with `buildComboboxEntries(interfaces, customAddress)` returning the entry list the UI maps over. |
| `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts` | Replace `mergeForSelect` tests with `buildComboboxEntries` tests. |
| `src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` | Swap `Select` for `Popover + Command`; add `open`/`query`/`customAddress` state. |
| `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx` | **New.** Render tests via `@testing-library/react`. |
No changes to: `mobile/app/pair-scan.tsx`, `MobilePairingQrSection.tsx`, `use-mobile-install-qr.ts`, or any main-process code. The QR generation pipeline already consumes `selectedAddress: string`, which is all the new flow produces.
## Module 1: `parseManualNetworkAddress`
```ts
// src/shared/network/manual-address.ts
export type ParseManualAddressResult =
| { ok: true; address: string }
| { ok: false; error: string }
export function parseManualNetworkAddress(input: string): ParseManualAddressResult
```
**Rules** (in order):
1. `input.trim()` must be non-empty. Otherwise `{ ok: false, error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' }`.
2. Reject any input containing whitespace anywhere; reject any input longer than 253 chars (DNS hostname cap).
3. Accept if it matches the IPv4 grammar (four dotted octets, each 0255). No leading zeros except for `0` itself.
4. Accept if it matches the Tailscale MagicDNS hostname grammar:
- Regex (case-insensitive): `/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*\.ts\.net$/`
5. Otherwise return the same error as (1).
Pure function, no React, no I/O. Unit-testable in isolation.
## Module 2: `buildComboboxEntries`
```ts
// src/renderer/src/components/settings/mobile-network-interface-selection.ts
export type MobileNetworkInterface = { name: string; address: string }
export type ComboboxEntry =
| { kind: 'interface'; iface: MobileNetworkInterface }
| { kind: 'use-query'; address: string } // only emitted when current query is valid
export function buildComboboxEntries(
interfaces: readonly MobileNetworkInterface[],
query: string
): readonly ComboboxEntry[]
```
**Behavior:**
- Trim `query`; if empty, return only `kind: 'interface'` entries from `interfaces` (no `use-query`).
- If `query` is non-empty, behavior branches on `parseManualNetworkAddress(query)`:
- **Valid query:** skip substring filtering and keep every interface visible (so the user can pivot to an existing interface mid-typing). Emit each as `kind: 'interface'`.
- **Invalid query:** filter `interfaces` by case-insensitive substring match on `iface.address` OR `iface.name`. Emit each as `kind: 'interface'`. If the filter yields zero matches, fall back to the full `interfaces` list (so the user always sees the available options, never an empty list mid-typing).
- After the interface entries, if the query parsed as valid AND no emitted interface has an `address` exactly equal to `parsed.address`, append `{ kind: 'use-query', address: parsed.address }`. (Suppression happens regardless of whether filtering ran, because valid queries skip filtering entirely — the check is against the visible interface list, which for valid queries is the full list.)
- Order: interface entries first (stable, in input order — either filtered or the full list per the branch above), then the optional `use-query`.
- The `selectRefreshedNetworkAddress` function is **kept** unchanged — it's still the rule that decides the *initial* `selectedAddress` when no manual entry exists. The UI calls it on mount and on Refresh; afterwards, the combobox owns the selection.
## Module 3: `MobileNetworkInterfaceSection` UI
Outer JSX (header text, description, Generate QR button, Refresh button, Tailnet accordion) is untouched. Only the inner selection control is replaced.
**State:**
```ts
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [customAddress, setCustomAddress] = useState<string | null>(null)
```
`customAddress` is the last value the user confirmed via the `use-query` entry. It is the "session-scoped" custom selection that the trigger displays with the `(custom)` label. It is reset when the settings pane unmounts (React unmount handles this automatically; no global store involved).
**Trigger:** A `Button` styled like the existing `SelectTrigger` (`min-w-[220px]`, `size="sm"`). Label uses the same `formatInterfaceLabel` helper:
```ts
function formatInterfaceLabel(iface: { name: string; address: string }): string {
return `${iface.address} (${iface.name})`
}
```
For the custom selection the rendered iface is `{ name: 'custom', address: customAddress }`, so the trigger shows `100.64.1.20 (custom)`.
**Popover body:** `Command` containing:
- `CommandInput` with placeholder `Search or type an address…` and `value={query}` / `onValueChange={setQuery}`.
- `CommandList` containing:
- `CommandEmpty` shown only when no interfaces match AND `parseManualNetworkAddress(query)` is invalid (i.e., truly nothing to pick).
- One `CommandItem` per `kind: 'interface'` entry from `buildComboboxEntries(networkInterfaces, query)`. `onSelect` calls `onSelectedAddressChange(iface.address)`, `setCustomAddress(null)`, `setQuery('')`, `setOpen(false)`.
- Optional visual separator (e.g., `CommandSeparator`) before the `use-query` entry.
- One `CommandItem` for `kind: 'use-query'` (only present when query is valid). Label: `Use "<query>"`. `onSelect` calls `onSelectedAddressChange(address)`, `setCustomAddress(address)`, `setQuery('')`, `setOpen(false)`.
**Controlled cmdk selection:** Copy the controlled-`commandValue` pattern from `src/renderer/src/components/agent/AgentCombobox.tsx` (imports `createAgentComboboxCommandState`, `resolveAgentComboboxCommandState`, `updateAgentComboboxCommandValue` from `@/components/agent/agent-combobox-command-state`, plus the `Command`, `CommandEmpty`, `CommandInput`, `CommandItem`, `CommandList` primitives from `@/components/ui/command`) so that hovering the footer doesn't leave a stale highlight on a list item. The exact state shape will be minimal — only one list, no footer-group complexity — so the borrowed helpers are sufficient. No new helpers are introduced in this design.
**Validation feedback:**
- When `query` is non-empty and invalid, render a one-line `text-xs text-statusRed` message directly below the trigger: `"Enter an IPv4 address or Tailscale MagicDNS hostname"`. Use the existing `statusRed` token from the theme to stay style-guide compliant.
- The `use-query` entry only appears when valid; no need to disable it.
**Refresh button:** Unchanged. Calls `onRefreshNetworkInterfaces`. The combobox re-renders with the new `networkInterfaces`; `query` and `customAddress` are preserved (user might be mid-typing).
**Generate QR button:** Unchanged. Disabled when `!selectedAddress`. No new branches.
## Data flow
```
networkInterfaces (prop, refreshed by parent)
buildComboboxEntries(networkInterfaces, query)
CommandList rows
▼ onSelect
onSelectedAddressChange(string) ─► parent re-renders MobilePairingQrSection
─► QR is regenerated with new endpoint
```
The parent of `MobileNetworkInterfaceSection` (whichever Settings tab owns it) already maintains `selectedAddress` and re-passes it down. This design does not change that contract.
## Edge cases
1. **Duplicate manual entry vs. existing interface**`buildComboboxEntries` suppresses the `use-query` entry whenever the parsed query exactly equals an emitted interface's `address`. For valid queries the visible list is the full interface list (no substring filter runs), so the suppression check is against every interface, not just filtered ones. The user lands on the existing interface row instead of a duplicate.
2. **OS discovers the manual address later** — If `customAddress === '100.64.1.20'` and a refresh surfaces `100.64.1.20 (tailscale0)`, both are valid options; the user's selection stays. A future iteration may add a "merge" action; out of scope here.
3. **Empty `networkInterfaces`** — All-interface list is empty. If `query` is also empty, `CommandEmpty` shows. If `query` is valid, the `use-query` entry still appears so the user can type an address even when nothing is enumerated. The trigger shows `No interfaces found`.
4. **Manual address becomes unreachable at pair time** — Not handled here. The QR generation succeeds; `pair-scan.tsx` already surfaces "Cannot connect — same network?" on failure.
5. **Closing the popover with an invalid query typed**`customAddress` and `selectedAddress` are unchanged. Next open starts with an empty `query`.
## Testing
**`src/shared/network/manual-address.test.ts`**
- Accepts: `0.0.0.0`, `255.255.255.255`, `192.168.1.24`, `100.64.1.20`.
- Rejects: `''`, `' '`, `'1.2.3'`, `'1.2.3.4.5'`, `'256.0.0.1'`, `'01.02.03.04'` (leading zeros), `'192.168.1.24 '` (trailing space).
- Accepts MagicDNS: `my-mac.ts.net`, `my-mac.tail-abcd.ts.net`, `a.b.c.d.ts.net`.
- Rejects MagicDNS: `my-mac` (no `.ts.net`), `my-mac.ts.com`, `-foo.ts.net`, `MY-MAC.TS.NET` is accepted (case-insensitive).
- Rejects anything > 253 chars; rejects whitespace anywhere.
- Pure unit tests; no React, no mocks.
**`mobile-network-interface-selection.test.ts`**
- `buildComboboxEntries([LAN, TAILNET], '')` returns two interface entries, no `use-query`.
- `buildComboboxEntries([LAN, TAILNET], '100')` (invalid query) returns the tailnet interface only — substring filter on `100.64.1.20` matches `100` — and no `use-query` because the query did not parse.
- `buildComboboxEntries([LAN, TAILNET], '100.64.1.20')` (valid query) returns both interface entries (valid queries skip substring filtering) AND suppresses `use-query` because the parsed address equals an existing interface's `address`.
- `buildComboboxEntries([LAN, TAILNET], 'my-mac.tail-abcd.ts.net')` (valid query) returns both interface entries (valid queries skip substring filtering) plus a `use-query` with the trimmed address.
- `buildComboboxEntries([], '1.2.3.4')` returns just `use-query`.
**`MobileNetworkInterfaceSection.test.tsx`** (new)
- Open popover, type `100.64.1.20`, click `Use "100.64.1.20"` → trigger label becomes `100.64.1.20 (custom)`, query clears.
- Type `not-an-address` → error message renders, no `Use …` row.
- Type `192.168.1.24` (matches `en0`) → no `Use …` row; clicking the existing interface selects it as `en0`, not `custom`.
- Trigger label `No interfaces found` shown when `networkInterfaces` is empty and no manual selection.
## Out of scope
- Persistent storage of manual addresses across sessions (user explicitly chose session-scoped).
- IPv6, port suffixes, non-Tailscale hostnames (rejected by the parser).
- Mobile-side endpoint override (separate flow; see `mobile/app/pair-scan.tsx`).
- Main-process changes — the renderer has enough information already.
## Open questions for reviewer
1. Should the error message stay in English-only here, or get the same `translate('auto.…', 'fallback')` wrap as the rest of the section? Recommend: wrap it for consistency, since the rest of the component already uses `translate()`.
2. Should `customAddress` survive a "Refresh" click? Recommend yes — user might be mid-typing during a VPN reconnect. Confirmed in Edge case 5 above.
3. Should `CommandInput` accept paste of a multi-line string (e.g. user pastes `orca://pair?code=…`)? Recommend: no special handling; the existing trim+validate treats it as an invalid address and shows the error. Pair-URL paste remains the path through `pair-scan.tsx`.
+17
View File
@@ -0,0 +1,17 @@
# Durable Docs
Keep this folder for versioned reference docs that are meant to survive past a single design or implementation pass.
## What Goes Here
- Stable reference material.
- Public-facing docs that are not part of the root README.
- Docs that other checked-in files link to.
- Telemetry availability notes that dashboard authors need after the original design or implementation branch is gone. See [Telemetry Availability](./telemetry-availability.md).
- Headless Linux server setup for remote `orca serve` hosts. See [Headless Linux Server](./headless-linux-server.md).
- Feature education state, interaction tracking, and retention analytics notes that define how contextual tours are persisted and measured. See [Feature Education State](./feature-education-state.md), [Feature Discovery Interaction Tracking](./feature-discovery-interaction-tracking.md), and [Feature Education Retention Analytics](./feature-education-retention-analytics.md).
- New-user parallel work telemetry notes that define how the parallel-work tour and setup guide should be measured against retention. See [New User Parallel Work Telemetry](./new-user-parallel-work-telemetry.md).
## What Stays Out
Ephemeral design notes, implementation sketches, and planning docs should stay as local Markdown files under `docs/`. They are ignored by default so they do not get checked in accidentally.
@@ -0,0 +1,341 @@
# Agent Session Resume Evidence for Sleep/Wake
Date: June 5, 2026
Scope: issue [stablyai/orca#1796](https://github.com/stablyai/orca/issues/1796), current worktree code, installed agent CLIs on this machine, cloned open-source CLI repos under `/tmp/orca-agent-resume-research`, and official provider docs where available.
## Executive conclusion
Orca can implement one-click agent resume on wake, but it cannot use terminal PTY IDs, pane keys, tab IDs, or worktree IDs as the agent session ID. Those are Orca terminal identifiers. The required value is the provider's conversation/session/thread ID.
For several first-tier providers, the exact provider ID is available in hook payloads today, but Orca drops it during normalization:
- Claude: hook payloads include `session_id`; resume command is `claude --resume <session_id>`.
- Codex: hook runtime sends `session_id`; resume command is `codex resume <session_id>`.
- Gemini: hook payloads include `session_id`; resume command is `gemini --resume <session_id>`.
- Antigravity: hook payloads include `conversationId`; resume command is `agy --conversation <conversationId>`.
- OpenCode: Orca's managed plugin already reads OpenCode `sessionID`; resume command is `opencode --session <sessionID>`.
- Droid: hook payloads include `session_id`; resume command is `droid --resume <session_id>`.
- Grok: hook payloads include `sessionId`/`session_id`; resume command is `grok --resume <sessionId>`.
The current blocker is not primarily "how do we resume?" The exact resume action is to spawn a new agent process on wake in the same workspace/pane context using the provider-specific resume command. The blocker is that Orca's current hook event and status types have no durable provider-session field, so the ID is discarded before sleep.
## Current Orca behavior
Issue #1796 is accurate about current behavior. The issue says sleep loses agent type, session ID, last prompt, and resume command because `dropAgentStatusByWorktree` wipes rows before wake can offer resume.
Verified current code path:
- Sleep calls terminal shutdown with identifiers preserved, not provider conversation metadata. See `src/renderer/src/components/sidebar/sleep-worktree-flow.ts`.
- `shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true })` preserves terminal IDs/layout wake hints, then calls `dropAgentStatusByWorktree(worktreeId)`. See `src/renderer/src/store/slices/terminals.ts`.
- `agentStatusByPaneKey` is explicitly documented as "Real-time only - lives in renderer memory, not persisted to disk." See `src/renderer/src/store/slices/agent-status.ts`.
- `WorkspaceSessionState` persists terminal/browser/editor/SSH state, including terminal layouts and remote relay PTY IDs, but no sleeping-agent records. See `src/shared/types.ts`, `src/renderer/src/lib/workspace-session.ts`, and `src/shared/workspace-session-schema.ts`.
- `AgentStatusEntry` has `state`, `prompt`, timestamps, `agentType`, pane/tab/worktree attribution, tool previews, assistant preview, and orchestration context. It has no provider session ID or resume command field. See `src/shared/agent-status-types.ts`.
The existing sleep persistence is terminal-resume metadata. The feature needs provider-conversation-resume metadata.
## Hook pipeline gap
The current local and SSH hook wire shapes drop provider IDs:
- `AgentHookEventPayload` in `src/shared/agent-hook-listener.ts` includes pane/tab/worktree, prompt cache fields, hook event name, Claude tool/subagent IDs, and normalized `payload`. It has no provider session ID field.
- `AgentHookRelayEnvelope` in `src/shared/agent-hook-relay.ts` mirrors that normalized shape for SSH. Its comment states the relay normalizes before sending the envelope. Therefore remote/SSH events lose provider IDs at the relay boundary too unless the new field is added to the shared envelope.
- `ParsedAgentStatusPayload` in `src/shared/agent-status-types.ts` includes only status fields: `state`, `prompt`, `agentType`, `toolName`, `toolInput`, `lastAssistantMessage`, `interrupted`.
Implementation implication: provider-session metadata must be extracted before normalization completes and must be carried through both the local HTTP path and the SSH relay path.
## Installed CLI versions checked
These exact binaries were available on this machine:
| Agent | Version checked |
| --- | --- |
| Claude Code | `2.1.165` |
| Codex | `codex-cli 0.137.0` |
| Gemini | `0.44.0` |
| OpenCode | `1.15.13` |
| Cursor Agent | `2025.09.18-7ae6800` |
| Antigravity | `1.0.5` |
| Droid | `0.122.0` |
| Grok | `grok 0.2.22 (967574cb117) [stable]` |
| Pi | `0.72.1` |
| Amp, Copilot, Command Code, Hermes | Not installed locally |
## Provider matrix
Legend:
- `Exact supported`: exact resume command and exact ID source are both verified.
- `Resume verified, ID capture missing`: CLI can resume, but current Orca hook path does not expose a durable exact ID.
- `ID seen, resume not verified`: Orca/source exposes an ID, but no exact resume CLI command was verified.
- `Out of current hook scope`: Orca has no current hook source for this TUI agent.
| Agent | Resume command evidence | Exact ID source evidence | Current Orca support status |
| --- | --- | --- | --- |
| Claude | `claude --resume <session_id>` verified by local `claude --help` and Anthropic CLI docs. | Claude hook docs show hook input includes `session_id` and `transcript_path`; current Orca normalizer drops `session_id`. | Exact supported after adding provider-session metadata extraction. |
| Codex | `codex resume <session_id>` verified by local help and cloned Codex source `codex-rs/utils/cli/src/resume_command.rs`. | Cloned Codex source `codex-rs/core/src/hook_runtime.rs` sends `session_id` on `SessionStart`, `PreToolUse`, `PermissionRequest`, and `PostToolUse` hook requests. | Exact supported after extraction. |
| Gemini | `gemini --resume <session_id>` verified by local help and Gemini docs. | Gemini hooks reference says all hooks receive `session_id` and `transcript_path`. | Exact supported after extraction. |
| Antigravity | `agy --conversation <uuid>` verified by local help and Antigravity docs. | Antigravity hook docs list `conversationId` as the active conversation UUID. | Exact supported after extraction. |
| OpenCode | `opencode --session <sessionID>` verified by local help, official docs, and cloned OpenCode TUI source. | Orca's OpenCode plugin already reads `event.properties?.sessionID` before posting. | Exact supported after preserving `sessionID`. |
| Droid | `droid --resume <sessionId>` verified by local help and Factory CLI docs. | Factory hooks reference includes `session_id`; Orca installs Droid hooks and already subscribes to `SessionStart`. | Exact supported after extraction. |
| Grok | `grok --resume <SESSION_ID>` verified by local help. | Orca already reads `sessionId`/`session_id` in `getGrokChatHistoryPath`, but only to locate chat history. | Exact supported after preserving that same ID. |
| Cursor | `cursor-agent --resume <chatId>` and `cursor-agent resume` verified by local help and Cursor docs. | Current Orca Cursor hook install deliberately omits `sessionStart`/`sessionEnd`; the subscribed event set does not currently prove a chat ID source. `cursor-agent create-chat` was locally verified to return a UUID, but preallocation for Orca launches still needs an end-to-end test before relying on it. | Resume verified, ID capture missing. |
| Pi | `pi --session <path|id>`, `pi --resume`, and `pi --continue` verified by local help and Pi docs. | Orca's bundled Pi/OMP extension posts status events but does not include session file path, session UUID, or session name in the status payload. | Resume verified, ID capture missing. |
| OMP | Same launch family as Pi in Orca. | Orca reuses Pi extension API shape and does not include exact session pointer. | Resume verified by inheritance only from Pi-like CLI contract; exact OMP ID capture missing. |
| Amp | Orca's managed plugin posts `threadId: event.thread.id`. | Amp is not installed locally, and no exact Amp CLI resume command was verified in this investigation. | ID seen, resume not verified. |
| Hermes | Orca's managed Hermes plugin selects `session_id` for session/LLM/tool events. | Hermes is not installed locally, and no exact Hermes CLI resume command was verified in this investigation. | ID seen, resume not verified. |
| Copilot | Orca installs broad Copilot hooks. | Local Copilot CLI not installed; no exact resume command or session ID contract was verified. | Not supported for exact resume yet. |
| Command Code | Orca installs hooks for `PreToolUse`, `PostToolUse`, and `Stop`. | Local Command Code CLI not installed; no exact resume command or session ID contract was verified. | Not supported for exact resume yet. |
| openclaude, autohand, aider, goose, kilo, kiro, crush, aug, cline, codebuff, continue, kimi, mistral-vibe, qwen-code, rovo, openclaw | Not investigated to exact-resume standard. | Not in `HOOK_SOURCE_BY_PATHNAME` today. | Out of current hook scope. |
## Exact resume behavior by provider
### Claude
Verified:
- Local `claude --help` shows `--continue`, `--resume [value]`, `--session-id <uuid>`, `--fork-session`, and `--no-session-persistence`.
- Anthropic CLI docs show `claude -r "<session-id>" "query"` and document `--resume` as resuming a specific session by ID.
- Claude hooks docs show hook input includes `session_id` and `transcript_path`.
Resume command:
```sh
claude --resume <session_id>
```
If Orca stores `lastPromptSummary` separately, it should not pass that summary as a new user prompt by default. The one-click resume action should reopen the prior conversation. Auto-submitting a summary would create a new turn.
### Codex
Verified:
- Local `codex resume --help` accepts `[SESSION_ID] [PROMPT]`, where the session target is a UUID or session name.
- Cloned Codex source `codex-rs/utils/cli/src/resume_command.rs` formats `codex resume <thread_id>`.
- Cloned Codex source `codex-rs/core/src/hook_runtime.rs` sends `session_id` on hook requests.
- Cloned Codex source has tests asserting resumed root sessions use the thread ID as session ID.
Resume command:
```sh
codex resume <session_id>
```
Avoid `codex resume --last` for this feature when an exact ID is available. `--last` can resume the wrong conversation if another Codex session ran after the slept workspace.
### Gemini
Verified:
- Local `gemini --help` shows `--resume`, `--session-id`, `--session-file`, and `--list-sessions`.
- Gemini official hooks reference states all hook input includes `session_id` and `transcript_path`.
- Gemini official configuration docs describe `--resume [session_id]` and state that a UUID can be supplied.
Resume command:
```sh
gemini --resume <session_id>
```
### Antigravity
Verified:
- Local `agy --help` shows `--continue` and `--conversation`.
- Antigravity conversation docs show `agy --continue` for most recent and `agy --conversation <uuid>` for a specific session.
- Antigravity hooks docs define common hook input `conversationId`.
Resume command:
```sh
agy --conversation <conversationId>
```
### OpenCode
Verified:
- Local `opencode --help` shows `--continue` and `--session`.
- Official OpenCode docs list `--session` as the session ID to continue.
- Cloned OpenCode source `packages/opencode/src/cli/cmd/tui/thread.ts` defines `--session` and passes it into TUI args as `sessionID`.
- Cloned OpenCode source `validate-session.ts` validates the supplied session ID through the OpenCode client.
- Orca's managed OpenCode plugin reads `event.properties?.sessionID`.
Resume command:
```sh
opencode --session <sessionID>
```
### Droid
Verified:
- Local `droid --help` shows `--resume [sessionId]`, `--fork <sessionId>`, and `--cwd`.
- Factory CLI docs list resume behavior and `droid exec -s <id>` for exec mode.
- Factory hooks reference says `SessionStart` has a `resume` matcher and hook input includes `session_id`.
Resume command:
```sh
droid --resume <session_id>
```
### Grok
Verified:
- Local `grok --help` shows `--resume [<SESSION_ID>]`, `--continue`, `--cwd`, and `--restore-code`.
- Orca already reads `sessionId`/`session_id` in `getGrokChatHistoryPath`.
Resume command:
```sh
grok --resume <sessionId>
```
### Cursor
Verified:
- Local `cursor-agent --help` shows `--resume [chatId]`.
- Cursor docs show listing prior chats with `cursor-agent ls`, resuming latest with `cursor-agent resume`, and resuming a specific conversation with `cursor-agent --resume="chat-id-here"`.
- Local help shows `cursor-agent create-chat` creates an empty chat and returns its ID.
- Running `cursor-agent create-chat` locally returned UUID `1fff2e62-9dd0-4cf3-9d8c-3569fea0aff7`, proving the command can preallocate a chat ID without an initial prompt.
Not verified:
- Current Orca Cursor hook install subscribes to `beforeSubmitPrompt`, `stop`, tool events, approval events, and `afterAgentResponse`, but intentionally does not subscribe to `sessionStart`/`sessionEnd`.
- I did not verify a current subscribed Cursor hook payload containing `chatId`.
- I did not run `cursor-agent --resume <chatId> <prompt>` because that would start a real agent turn and may make workspace changes.
Conclusion: exact Cursor resume is CLI-supported, but Orca currently needs either a verified hook payload chat ID or a launch preallocation strategy that is tested end to end.
### Pi and OMP
Verified:
- Local `pi --help` shows `--continue`, `--resume`, `--session <path|id>`, `--fork <path|id>`, `--session-dir`, and `--no-session`.
- Pi docs state `/resume` opens a picker and `pi -r` opens the same picker at startup.
- Orca's Pi/OMP extension posts status events from Pi's extension API, but the status events do not include session path, UUID, or session name.
Resume command:
```sh
pi --session <path-or-id>
```
Conclusion: exact Pi resume is CLI-supported, but current Orca capture is missing the exact pointer.
## What Orca should store
Store a durable record per sleeping agent pane, not per worktree only:
```ts
type SleepingAgentSessionRecord = {
id: string
worktreeId: string
tabId?: string
paneKey: string
connectionId: string | null
agentType: TuiAgent
providerSession: {
kind: 'session_id' | 'conversation_id' | 'thread_id' | 'chat_id' | 'path'
value: string
}
resumeCommand: {
argv: string[]
cwd?: string
}
lastPrompt: string
lastAssistantMessage?: string
capturedAt: number
retentionUntil?: number | null
}
```
Important constraints:
- `resumeCommand.argv` should be structured argv, not a shell string. This avoids cross-platform quoting bugs.
- Do not persist a terminal PTY ID as `providerSession.value`.
- For SSH, `cwd` and the command execute on the remote host. The provider session ID also belongs to the remote CLI's session store.
- For local workspaces, execute locally in the worktree path.
- For auto-resume, launch exactly one process per sleeping agent record and guard against double-click/duplicate wake races.
## Provider extraction map
This is the extraction map that is supported by the evidence above:
| Hook source | Provider session field(s) to extract | Resume argv |
| --- | --- | --- |
| `claude` | `session_id` | `['claude', '--resume', id]` |
| `codex` | `session_id` | `['codex', 'resume', id]` |
| `gemini` | `session_id` | `['gemini', '--resume', id]` |
| `antigravity` | `conversationId` | `['agy', '--conversation', id]` |
| `opencode` | `sessionID` | `['opencode', '--session', id]` |
| `droid` | `session_id` | `['droid', '--resume', id]` |
| `grok` | `sessionId`, fallback `session_id` | `['grok', '--resume', id]` |
| `amp` | `threadId` | not enabled until Amp resume command is verified |
| `hermes` | `session_id` | not enabled until Hermes resume command is verified |
## Required implementation shape
1. Add a provider-session metadata type to shared hook types.
2. Extract provider session evidence in `parseAgentHookEvent` from raw `hookPayloadRecord` before normalizing to `ParsedAgentStatusPayload`.
3. Carry it in `AgentHookEventPayload`.
4. Carry it in `AgentHookRelayEnvelope` so SSH does not lose it.
5. Store latest provider-session evidence by pane key alongside live agent status.
6. On sleep, snapshot the live agent status plus latest provider-session evidence before `dropAgentStatusByWorktree`.
7. Persist sleeping-agent records in `WorkspaceSessionState` and schema.
8. On wake, render sleeping resumable rows separately from active rows and launch the provider-specific structured argv when the user resumes.
9. Clear records on explicit worktree removal, not on sleep.
10. If the provider session no longer exists, let the provider CLI fail and surface that failure without deleting the record until the user dismisses it.
## Open questions that remain genuinely unverified
- Cursor: whether any currently subscribed Cursor hook payload carries `chatId`. If not, the only plausible exact strategy is preallocating a chat with `cursor-agent create-chat` at launch and starting/resuming that known chat. That needs an end-to-end test before productizing.
- Pi/OMP: whether the extension API can expose the current session file/path/UUID. Current Orca extension does not forward it.
- Amp: whether the Amp CLI has an exact thread resume command matching `threadId`.
- Hermes: whether Hermes has an exact session resume CLI command matching `session_id`.
- Copilot and Command Code: exact resume command and exact hook session field were not verified because the CLIs were not installed locally and no sufficient primary docs/source were found during this pass.
- Non-hook TUI agents: no exact support should be promised until Orca adds a hook/metadata capture path for each.
## Sources
Local Orca source:
- `src/shared/agent-hook-listener.ts`
- `src/shared/agent-hook-relay.ts`
- `src/shared/agent-status-types.ts`
- `src/shared/types.ts`
- `src/shared/tui-agent-config.ts`
- `src/renderer/src/store/slices/terminals.ts`
- `src/renderer/src/store/slices/agent-status.ts`
- `src/renderer/src/lib/workspace-session.ts`
- `src/shared/workspace-session-schema.ts`
- `src/main/opencode/hook-service.ts`
- `src/main/amp/hook-service.ts`
- `src/main/hermes/hook-service.ts`
- `src/main/pi/agent-status-extension-source.ts`
- `src/main/cursor/hook-service.ts`
Cloned source:
- `/tmp/orca-agent-resume-research/codex`
- `/tmp/orca-agent-resume-research/gemini-cli`
- `/tmp/orca-agent-resume-research/opencode`
Official docs:
- GitHub issue: https://github.com/stablyai/orca/issues/1796
- Claude CLI reference: https://docs.anthropic.com/en/docs/claude-code/cli-usage
- Claude hooks reference: https://code.claude.com/docs/en/hooks
- Gemini hooks reference: https://geminicli.com/docs/hooks/reference/
- Cursor CLI parameters: https://docs.cursor.com/en/cli/reference/parameters
- Cursor CLI usage: https://docs.cursor.com/en/cli/using
- OpenCode CLI docs: https://opencode.ai/docs/cli/
- Antigravity conversation docs: https://antigravity.google/docs/cli-conversations
- Antigravity hooks docs: https://www.antigravity.google/docs/hooks
- Factory Droid hooks reference: https://docs.factory.ai/cli/configuration/hooks-reference
- Factory Droid CLI reference: https://docs.factory.ai/cli/configuration/cli-reference
- Pi docs: https://pi.dev/docs/latest/tree
+146
View File
@@ -0,0 +1,146 @@
# Direct URL Or File Entry
## Problem
The tab bar `+` menu only offers fixed actions: terminal, browser, new markdown, and open markdown in `src/renderer/src/components/tab-bar/TabBar.tsx`. It has no text entry point for a user who already knows the URL or file path they want.
Quick Open already loads files for the active worktree through `listRuntimeFiles`, watches the active SSH target status, excludes nested linked worktrees, ranks via `prepareQuickOpenFiles`/`rankQuickOpenFiles`, and opens a selected match with `openFile`. That flow is modal and file-only. It does not live in the `+` menu, does not accept URLs, and cannot create a named new file from the typed query.
The runtime file client has the required local/SSH/runtime primitives, with caveats:
- `listRuntimeFiles(context, { rootPath, excludePaths })` returns relative paths only and can fail for auth, missing provider, ripgrep/size, or stale worktree reasons.
- `statRuntimePath(context, absolutePath)` is a one-path existence/type check.
- `createRuntimePath(context, absolutePath, 'file')` creates a single empty file and creates parent directories on the current local, SSH, and runtime-backed paths. Directory creation has separate no-clobber/recursive semantics elsewhere in the file stack, so v1 should create files directly and not expose directory creation as a separate action.
## Goal
Add an entry field at the top of the tab bar `+` menu so users can type:
1. A URL to open an Orca browser tab.
2. An existing file name/path to open an editor tab.
3. A new relative file path to create in the active worktree and open.
The behavior must work from both the titlebar tab strip and split-group tab strips. File operations must route through `RuntimeFileOperationArgs`; browser/editor creation must preserve the target group.
## Non-goals
- Do not replace global Quick Open.
- Do not add persisted launcher state.
- Do not support standalone directory creation.
- Do not open external URLs outside Orca.
- Do not make URL/file detection configurable.
## Design
1. Add a `TabBarCreateEntry` surface inside `TabBar`'s dropdown content above the fixed rows. Use the existing `DropdownMenu` shell, but render the input in a plain form/container, not as a `DropdownMenuItem`; Radix menu item typeahead/selection should not own text input keystrokes. Stop propagation only where required for input typing, and close the dropdown only after a successful submit.
2. Extend `TabBarProps` with a presentation callback that resolves success by returning and failure by throwing:
```ts
onOpenEntry?: (args: { query: string; worktreeId: string; groupId: string }) => Promise<void>
```
`TabBar` owns input text, pending state, focus, and dropdown close behavior. It does not create browser tabs or files. `groupId` should be `groupId ?? worktreeId`, matching the existing `resolvedGroupId` fallback.
3. Add a small hook/helper pair instead of duplicating Quick Open logic:
- `useTabEntryFileList` loads the file list when the menu opens, using the same inputs as `QuickOpen`: active worktree path, `getConnectionId(worktreeId)`, nested worktree exclusions, and active SSH target status. It cancels stale requests on close or key changes.
- A pure classifier/opening helper accepts the query, file-list snapshot, load/error state, worktree metadata, runtime context, and target group.
4. Wire the callback in both owners:
- `Terminal.tsx` titlebar fallback resolves the current active worktree and target group the same way `handleNewTab` / `handleNewBrowserTab` do.
- `useTabGroupWorkspaceModel` passes its explicit `worktreeId` and `groupId`. Do not rely on ambient `activeGroupIdByWorktree`; split-group `+` can be invoked from an unfocused group.
5. Classify submissions in this order:
- Empty after trim: reject inline.
- Explicit URL: accept only `http://` and `https://` URLs with a parseable host.
- Existing file: once the file-list snapshot is ready, normalize query separators for matching, prefer exact relative-path match, then exact basename match, then `rankQuickOpenFiles`. Before opening, `statRuntimePath` the matched absolute path and reject directories/stale missing matches instead of blindly opening stale list entries.
- Host-like URL: only after there is no existing file match, accept strict bare hosts such as `example.com`, `localhost:3000`, or `127.0.0.1:3000`, normalized to `https://...` when no scheme is present. Do not run host-like URL parsing for bare input containing `/` or `\`; `new URL('https://docs/readme.md')` parses, so parsing alone is not a path/file guard. Also reject common source/document filename extensions such as `md`, `ts`, `tsx`, `js`, `jsx`, `json`, `yml`, `yaml`, `toml`, `css`, `html`, and `py` so `README.md` and `src/foo.test.ts` stay file/create candidates.
- New file: only after file listing has completed successfully with no existing-file match and no host-like URL match. Treat the query as a relative worktree path. If listing fails, allow explicit `http://` / `https://` URLs only; keep bare host-like inputs blocked because they cannot be disambiguated from files.
6. Validate new file paths before joining:
- Reject POSIX absolute paths, Windows drive paths, UNC paths, `~`, empty path, trailing slash, control characters, `.` / `..` segments, and empty raw segments such as `a//b`.
- Normalize `\` to `/` only after absolute-path checks, then run segment validation on the normalized path too so traversal like `a\..\b` is still rejected.
- Build the absolute path with `joinPath(worktreePath, relativePath)`, then create with `createRuntimePath(context, absolutePath, 'file')`.
- On `EEXIST` / "exists", immediately `statRuntimePath`; if it is now a file, open it. If it is a directory, show an error. This handles another window/process winning the create race.
7. Open actions:
- URL: in paired web clients, call `createWebRuntimeSessionBrowserTab({ worktreeId, url, targetGroupId: groupId })`; otherwise call `createBrowserTab(worktreeId, url, { activate: true, targetGroupId: groupId, title: url })`. Do not use `openNewBrowserTabInActiveWorkspace`; it only opens the default URL.
- Existing/new file: call `openFile(fileInfo, { preview: false, targetGroupId: groupId })` with `language: detectLanguage(relativePath)`. Include the active runtime environment owner as `runtimeEnvironmentId` through the normal `openFile` fallback; do not suppress runtime ownership.
8. Preserve current fixed actions. Existing `New Terminal`, `New Browser Tab`, `New Markdown`, `Open Markdown...`, and quick-launch rows remain below the entry and keep their current shortcuts/icons.
9. Surface errors with existing toast or compact inline text. Keep the dropdown open and preserve the query on validation/runtime errors. For Quick Open's special ripgrep guidance, either extract its parser/UI deliberately or show the cleaned error string; do not duplicate a private parser inline.
## Data Flow
- User opens the `+` menu.
- `TabBarCreateEntry` focuses the input and `useTabEntryFileList` starts/reuses the menu-local file-list request.
- User types; the menu may show the best existing-file match or "create file" affordance based on the current snapshot.
- Enter calls `onOpenEntry({ query, worktreeId, groupId })`.
- Helper classifies and dispatches:
- URL -> browser tab creation with target group.
- Existing file -> stat matched path -> `openFile(..., { targetGroupId })`.
- New file -> validate -> `createRuntimePath(..., 'file')` -> `openFile(..., { targetGroupId })`.
- Success closes the dropdown. Failure keeps it open.
## Edge Cases
- No active worktree: disable the entry, including URL entry, because Orca browser tabs are worktree-scoped.
- SSH/runtime connection not ready: mirror Quick Open by keying the list request on active target status. Non-URL submissions are disabled while loading/connecting to avoid creating a duplicate before the real list arrives.
- File listing failure: explicit `http://` / `https://` URL submissions still work; file and bare host-like submissions are blocked with the cleaned list error.
- Ambiguous host-like/file names: an existing listed file wins over bare host-like URL normalization. Explicit `http://` or `https://` input is the escape hatch when the user wants a browser tab despite a file-name collision.
- File list stale because another window/process added or removed a file: stat before opening matched files; handle create `EEXIST` by stat-and-open.
- Existing directory match: show an error; do not open as an editor tab.
- Internal spaces in file paths are allowed. Leading/trailing whitespace is trimmed. Control characters are rejected.
- Windows-style separators match existing relative paths after normalization, but Windows absolute and UNC paths are rejected for creation.
- Duplicate Enter while pending is disabled.
- Browser creation in paired web/mobile clients must use `createWebRuntimeSessionBrowserTab`; local desktop uses `createBrowserTab`.
- External file-watch invalidation is not required for v1. The menu-local list reloads on each open and on worktree/connection/status changes; successful create can close the menu without mutating the list.
## Test Plan
- Unit test URL classification: schemes, host-like domains, localhost/IP ports, listed file named `example.com` winning over host-like normalization, `README.md`/`readme.md`, `src/foo.test.ts`, `docs/readme.md`, whitespace, and invalid schemes.
- Unit test path validation: Windows/POSIX absolute paths, UNC, `~`, traversal, empty segments, trailing slash, control characters, spaces, and separator normalization.
- Unit test existing-file selection with `prepareQuickOpenFiles`/`rankQuickOpenFiles`: exact path beats basename, basename beats fuzzy, stale stat failure blocks open, directory stat blocks open.
- Unit/helper test new-file creation with `RuntimeFileOperationArgs`, `createRuntimePath`, `statRuntimePath`, EEXIST stat-and-open, and SSH/runtime connection context.
- Component test `TabBar`: input renders above fixed rows, focuses on open, typing does not close the menu, Enter awaits the callback, success closes, failure preserves text, fixed actions still fire.
- Store/model tests: titlebar and split-group callbacks pass `targetGroupId`; URL creation uses `createWebRuntimeSessionBrowserTab` in web runtime and `createBrowserTab` otherwise.
- Electron validation: open URL, open existing file by exact path/name, create nested new file, invalid traversal/absolute path error, and smoke-test existing menu actions.
## UI Quality Bar
- Follow `docs/STYLEGUIDE.md` and existing `DropdownMenu`/`Input` tokens. Do not add custom colors, shadows, or a modal-like panel inside the menu.
- The input is the first focus target and must not break menu keyboarding.
- The menu remains compact; loading, match preview, create preview, and error rows fit at the current menu width without overlap or awkward height jumps.
- Long typed paths scroll/truncate within the input; fixed rows retain icons, shortcuts, hover states, and dense spacing.
## Review Screenshots
Attach evidence to the PR conversation; do not commit images.
1. `+` menu open in a normal workspace with the entry focused and fixed rows visible.
2. Typed URL state.
3. Typed existing-file query with visible best match.
4. Typed new-file path/create state.
5. Rejected absolute/traversal path error.
6. Adjacent-feature smoke: fixed menu rows still visible and aligned.
## Rollout
1. Add classifier/path-validation helper and tests.
2. Add menu-local file-list hook by extracting the reusable Quick Open loading inputs, not by copying private UI-only parsing.
3. Add `TabBarCreateEntry` and wire it into `TabBar`.
4. Add titlebar and split-group callbacks.
5. Add component/store tests.
6. Run typecheck, lint, targeted tests, UI review, and Electron validation with screenshots.
## Lightweight Eng Review
- Scope: Correctly scoped to the tab bar `+` menu. The implementation must not mutate Quick Open behavior except for extracting reusable search/listing code.
- Architecture/data flow: Good if `TabBar` stays presentational and all worktree/runtime/group decisions live in owners plus a shared helper. The original `onOpenEntry(query, groupId?)` shape was under-specified; include `worktreeId` and a resolved `groupId`.
- Failure modes: Must explicitly handle loading list, failed list, stale list, directory matches, create races, no active worktree, no SSH provider yet, and paired web runtime browser creation.
- Feasibility: Specific URL browser tabs cannot go through `openNewBrowserTabInActiveWorkspace` because that action uses the default URL. `createRuntimePath(..., 'file')` is feasible and creates parent directories for current local/SSH/runtime paths, but do not expose recursive directory creation as a separate v1 behavior.
- Concurrency/invalidation: A menu-local file list is acceptable if every matched file is statted before open and `EEXIST` is handled on create. No global cache is needed.
- Performance/blast radius: Listing on menu open has Quick Open's cost class, but the menu is more casually opened than the modal; cancel stale loads and key requests tightly by worktree path, connection, exclusions, and SSH status.
- Tests: Add pure helper tests first; then component and store/model tests. Electron screenshots are required because Radix menu focus/input behavior is the highest-risk UI part.
- Residual risk: Host-like URL heuristics can surprise users. Keep the heuristic narrow and prefer file matches over host-like normalization whenever the query contains path separators or matches a listed file.
@@ -0,0 +1,68 @@
# Feature Discovery Interaction Tracking
This document defines local feature-interaction state used to decide whether Orca should still teach a feature with an education surface such as a tour or feature tip.
## Decision
Track first meaningful interaction plus local interaction count for education-targeted features in `PersistedUIState.featureInteractions`.
Do not upload this state as broad analytics. Product analytics should continue to use bounded telemetry events and downstream product events. Local interaction state answers a different question: "Has this user already found enough of this feature that education would be redundant?"
## Rules
- Add a `FeatureInteractionId` in `src/shared/feature-interactions.ts` before using it.
- Preserve `firstInteractedAt`, and increment `interactionCount` on each later meaningful interaction.
- Prefer explicit actions over passive visibility.
- Passive visibility is acceptable only when opening the surface is itself the product use, such as opening Tasks.
- Record after persisted UI is hydrated by using `recordFeatureInteraction(...)`; it no-ops before hydration.
- Keep IDs stable. If the meaning changes materially, add a new ID.
- Do not include user text, paths, URLs, repo names, branch names, hostnames, commands, prompts, or tokens in this state.
- Old records without `interactionCount` hydrate as `interactionCount: 1`.
## Feature Catalog
| Feature | Interaction ID | Record when | Education use |
| ------------------------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| Review notes to agent | `review-notes` | A diff or markdown review note is added, or review notes are marked sent to an agent. | Suppress or target a future review-notes tour/tip about adding line notes and sending focused feedback back to an agent. |
| AI commit generation | `ai-commit-generation` | AI commit-message generation is enabled or an AI commit message is generated. | Suppress future education about AI commit generation. No contextual tour is planned for this branch. |
| AI PR generation | `ai-pr-generation` | AI pull-request title/body/draft fields are generated. | Suppress future education about AI PR generation. No contextual tour is planned for this branch. |
| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Keep the existing Floating Workspace tour, and suppress future education about the global terminal/browser/markdown workspace. |
| Quick Commands | `quick-commands` | A terminal quick command is created or edited. | Suppress future tips about saved terminal commands. No contextual tour is planned for this branch. |
| Computer Use setup | `computer-use-setup` | Computer Use was selected in legacy onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress setup-focused tips once the user has started setup. |
| Computer Use | `computer-use` | A successful `computer.*` runtime method other than capability probing is handled. | Suppress future usage tips once an agent has actually invoked Computer Use. |
| Mobile pairing | `mobile-pairing` | Mobile is enabled or a mobile pairing QR/code is generated. | Suppress future mobile-pairing tips. No contextual tour is planned for this branch. |
| Browser element grab | `browser-grab` | Browser grab mode is started, an element is copied, or an element screenshot is copied. | Suppress future tips about grabbing page context once the user has used the element picker. |
| Browser annotations | `browser-annotations` | Browser annotation mode is started, an annotation is added, browser annotations are copied, or browser annotations are cleared. | Suppress future tips about annotating local pages and sending concrete UI feedback to an agent. |
| Cookie import | `cookie-import` | Browser cookies are imported from another browser or file, imported through runtime browser profile methods, or default imported cookies are cleared. | Suppress future cookie-import education without conflating cookie setup with Agent Browser Use itself. |
| Workspace board actions | `workspace-board-actions` | A card/status action, lane configuration, density change, pin drop, or board drag action is used. | Keep the existing workspace-board tour, and avoid repeating deeper board-action education after real use. |
| Automation creation | `automation-created` | A local automation or external Hermes cron is created. | Keep the existing Automations tour, and suppress creation-focused education after the user creates one. |
| Automation run | `automation-run` | A local or external automation run is manually queued. | Keep the existing Automations tour, and suppress run/inspection education after the user queues a run. |
| Resource Manager | `resource-manager` | The Resource Manager status-bar popover is opened, or its status-bar visibility is toggled in Appearance/status-bar controls. | Suppress future tips about CPU, memory, session, daemon, and workspace disk-scan controls after the user has found the manager. |
| Workspace cleanup / disk space | `workspace-cleanup` | The Space page opens, a workspace disk scan starts/cancels, or cleanup removes scanned workspace rows. | Suppress future tips about scanning workspace disk usage and reclaiming old workspace storage. |
| Ports | `ports` | The Ports popover opens, the Ports status-bar item is configured, external ports are expanded, or a port is opened/copied/stopped. | Suppress future tips about discovering and acting on workspace ports. |
| SSH | `ssh` | SSH status opens, SSH status-bar visibility changes, a target is added/imported/tested, or an SSH target is connected/disconnected. | Suppress future SSH setup/status tips after the user has interacted with remote target controls. |
| Provider usage tracking | `usage-tracking` | Stats & Usage is opened, Claude/Codex/OpenCode usage analytics are enabled, provider usage details are opened from the status bar, Gemini usage/OAuth is configured, or provider usage status-bar toggles are changed. | Suppress future tips about where to find token/rate-limit/usage tracking for Claude, Codex, Gemini, OpenCode, and related providers. |
| Claude account switching | `claude-account-switching` | A Claude managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about using multiple Claude accounts once the user has started account management. |
| Codex account switching | `codex-account-switching` | A Codex managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about Codex account switching and restart follow-up flows. |
| Workspace tabs | `terminal-tabs` | A workspace tab is created, reordered, renamed, recolored, pinned/unpinned, moved, or closed. | Suppress future tips about tab-level workspace organization. These are real workspace tabs, not workspace board cards. |
| Split panes | `terminal-panes` | A split group is created from the pane shortcut/menu, a split is resized, or panes are merged. | Suppress future tips about pane-level split workflows. |
| Tab splits | `tab-splits` | A workspace tab is moved into another pane or split into a new pane from a tab split/drop action. | Suppress future tips about tab-level split workflows separately from pane creation/resizing. |
| Agent Browser Use setup | `agent-browser-setup` | Browser Use was selected in legacy onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Browser Use setup. |
| Agent Browser Use | `agent-browser-use` | A successful non-profile `browser.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually driven Orca's browser. |
| Agent Orchestration setup | `agent-orchestration-setup` | Orchestration was selected in legacy onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Orchestration setup. |
| Agent Orchestration | `agent-orchestration` | A successful `orchestration.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually used orchestration. |
| Notifications | `notifications` | Notifications are enabled in onboarding/settings or a test notification is sent. | Suppress future notification setup tips. |
## Surface-Level Features
Orca also records surface-level interactions for feature areas where opening the surface is itself a meaningful discovery signal:
- `workspace-board`: workspace board opened
- `workspace-agent-sessions`: workspace terminal surface opened with agent split controls visible
- `browser`: non-blank browser page viewed
- `tasks`: Tasks page opened
- `automations`: Automations page opened
- `floating-workspace`: floating workspace opened
- `workspace-creation`: workspace creation flow opened
These remain intentionally separate from action-level IDs such as `workspace-board-actions`, `automation-created`, and `automation-run`. Surface-level IDs answer "has the user entered the feature area?" Action-level IDs answer "has the user performed the deeper workflow?"
@@ -0,0 +1,182 @@
# Feature Education Retention Analytics
This document records how Orca should use contextual-tour telemetry to evaluate retention and feature adoption.
## Decision
Track only contextual tour exposure and outcome in PostHog:
- `contextual_tour_shown`
- `contextual_tour_outcome`
Do not add broad telemetry that mirrors local education state, such as "feature interaction first recorded." Local state like `featureInteractions` exists to suppress redundant education on the user's machine; it is not the analytics source of truth.
Feature adoption analysis should join tour cohorts to existing downstream product events. Add new telemetry only for concrete user actions that are not already represented by an existing event.
Automatic contextual tours are limited to new users for this rollout. The local `contextualToursAutoEligible` flag is set once after persisted UI and onboarding state load: users still in first-run onboarding become eligible; users with closed onboarding become ineligible. This avoids surprising existing users with education for surfaces they may already understand.
## Product Questions
Use the data to answer:
- Do users who see contextual tours retain better at D1 and D7?
- Do completed tours correlate with higher downstream feature adoption than skipped or cancelled tours?
- Which tours have high skip/cancel rates and should be revised or suppressed?
- Which tours introduce features that users later use in real workflows?
## Event Contract
### `contextual_tour_shown`
Emitted once when a contextual tour first renders a measured target.
Payload:
- `tour_id`: `workspace-board`, `workspace-agent-sessions`, `browser`, `tasks`, `automations`, or `workspace-creation`
- `source`: bounded source enum from `src/shared/feature-education-telemetry.ts`
- `was_feature_previously_interacted`: boolean from local education state at the moment the tour is shown
### `contextual_tour_outcome`
Emitted once when a contextual tour ends.
Payload:
- `tour_id`: same enum as `contextual_tour_shown`
- `source`: same bounded source enum
- `outcome`: `completed`, `skipped`, or `cancelled`
- `steps_seen`: bounded integer
- `total_steps`: bounded integer
## Dashboard Plan
Create a PostHog dashboard named **Feature Education Retention**.
### Tile: Tour Exposure Volume
Question: How often are tours shown?
Insight:
- Event: `contextual_tour_shown`
- Breakdown: `tour_id`
- Visualization: stacked time series by day
Action:
- If volume is unexpectedly high, inspect gating before expanding tours.
- If a tour is never shown, verify the surface gate and target selectors.
### Tile: Tour Outcome Rate
Question: Which tours are completed, skipped, or cancelled?
Insight:
- Event: `contextual_tour_outcome`
- Breakdown: `tour_id` and `outcome`
- Visualization: stacked bar or table
Action:
- High skipped rate means copy, timing, or audience targeting needs revision.
- High cancelled rate means UI state is likely interrupting tours or targets are disappearing.
### Tile: Median Steps Seen
Question: How far do users get before leaving?
Insight:
- Event: `contextual_tour_outcome`
- Metric: median `steps_seen`
- Breakdown: `tour_id`
- Optional derived ratio: `steps_seen / total_steps`
Action:
- If most users see only step 1, shorten or reprioritize the tour.
- If later steps are rarely reached, do not rely on those steps for critical education.
### Tile: D1/D7 Retention By Tour Outcome
Question: Does tour status correlate with retention?
Insight:
- Cohort A: users with `contextual_tour_outcome` where `outcome = completed`
- Cohort B: users with `contextual_tour_outcome` where `outcome = skipped`
- Cohort C: users with `contextual_tour_outcome` where `outcome = cancelled`
- Cohort D: users with no `contextual_tour_shown`
- Retention: return to `app_opened` on day 1 and day 7
- Breakdown: `tour_id` where PostHog supports it, otherwise build one insight per tour
Action:
- Keep or expand tours whose completed cohort outperforms skipped/no-tour cohorts.
- Rework or suppress tours whose shown cohort underperforms no-tour users.
### Tile: Downstream Feature Adoption By Tour
Question: Do users later use the feature taught by the tour?
Build funnels from tour events to real product events:
- `tasks`: `contextual_tour_outcome` -> workspace creation from task-linked flows where existing telemetry exposes the source; if the current event is insufficient, add a targeted task-started-workspace event.
- `workspace-creation`: `contextual_tour_outcome` -> `workspace_created`
- `browser`: `contextual_tour_outcome` -> browser grab/annotation events. If these do not exist, add targeted events for the concrete actions, not a broad feature-interaction event.
- `automations`: `contextual_tour_outcome` -> automation creation/run events. If these do not exist, add targeted events for create/run.
- `workspace-board`: `contextual_tour_outcome` -> board-specific actions such as card moved/status changed. If these do not exist, add targeted events for those actions.
- `workspace-agent-sessions`: `contextual_tour_outcome` -> terminal pane split actions and later workspace creation.
Recommended funnel shape:
1. `contextual_tour_shown` filtered by `tour_id`
2. `contextual_tour_outcome` filtered by same `tour_id`, broken down by `outcome`
3. Downstream action event within 1 day and 7 days
Action:
- If completion improves downstream action conversion, keep the tour.
- If shown-but-skipped users still convert, the surface may be discoverable without a tour.
- If no downstream event exists, add the smallest action-specific telemetry needed for that feature.
## Telemetry Cost Rule
At 1,000 DAU, this PR should emit at most:
- One `contextual_tour_shown` per shown tour
- One `contextual_tour_outcome` per shown tour
- Currently one contextual tour per session
Expected launch volume: under 2,000 events/day at 1,000 DAU and lower for mature cohorts because existing users are not auto-eligible and `contextualToursSeenIds` fills in for eligible new users.
Do not add recurring, render-loop, polling, passive snapshot, or local-state-mirror events for this analysis.
## Privacy Rule
Payloads must remain low-cardinality and bounded. Do not include:
- prompts
- commands
- paths
- URLs
- hostnames
- repo names
- branch names
- user-entered text
- raw errors
- tokens or IDs beyond the existing anonymous telemetry identity
## Existing Users
Existing users should not receive automatic contextual tours from this rollout. We cannot reliably know whether they have already viewed or used every covered surface, so the least surprising default is to leave them alone and measure this launch on new-user cohorts.
Implementation:
- `contextualToursAutoEligible: false` for profiles whose onboarding is already closed when the rollout first runs
- `contextualToursAutoEligible: true` for profiles still in first-run onboarding when the rollout first runs
- automatic tour requests require `contextualToursAutoEligible === true`
- local `featureInteractions` still records supported surface entry for all users after UI hydration
For existing-user analysis, compare users by actual tour exposure and outcome only when they have exposure. Most existing users should fall into the no-tour baseline for this PR. If a later release adds manual tour entry points or truly new feature education, evaluate that release separately instead of reusing this rollout's eligibility rule.
+75
View File
@@ -0,0 +1,75 @@
# Feature Education State
Orca uses local persisted UI state to decide which education surfaces should appear for a user. Keep these concepts separate; they answer different questions and should not be collapsed into one flag.
For the feature-interaction catalog, see [`feature-discovery-interaction-tracking.md`](./feature-discovery-interaction-tracking.md). For retention analysis and dashboard construction, see [`feature-education-retention-analytics.md`](./feature-education-retention-analytics.md).
## State Types
`featureTipsSeenIds` answers: "Has Orca already surfaced this tip?"
Set this when a restart tip is opened, dismissed, or acted on. It prevents the same tip from reappearing after restart, crash, or dismissal. It does not mean the user used the feature.
`contextualToursSeenIds` answers: "Has Orca already surfaced this contextual tour?"
Set this after a tour has rendered a measured target, or when the user skips/completes the tour. It prevents the same tour from showing again. It does not mean the user used the feature.
`contextualToursAutoEligible` answers: "May this profile receive automatic contextual tours from this rollout?"
Missing means Orca has not classified the profile yet. On first hydrated launch after this change, the renderer sets it once:
- `true` when first-run onboarding is still open, which covers new users from this rollout forward
- `false` when onboarding is already closed, which covers existing users
Automatic contextual tours require this flag to be `true`. Existing users still get local feature interaction state recorded when they enter supported surfaces, but they are not auto-toured by this rollout.
`featureInteractions` answers: "Has the user actually interacted with this feature?"
This is the product-state signal for already-discovered features. It lives in `PersistedUIState.featureInteractions`, keyed by `FeatureInteractionId`, and stores the first local interaction timestamp plus a local interaction count:
```ts
{
tasks: {
firstInteractedAt: 1716500000000,
interactionCount: 3
}
}
```
Use this to suppress tips or tours that would teach a feature the user has already found on their own.
## Rules
- Define every trackable feature in `src/shared/feature-interactions.ts`.
- The `interaction` text must clearly state what counts as "used." Avoid vague labels like "seen" or "visited" unless passive visibility is truly the intended signal.
- Record only the first interaction. Repeated opens should not churn the user-data file.
- Record interactions only after persisted UI state is hydrated, so startup defaults cannot overwrite real user data.
- Do not rename or reuse ids. If semantics change materially, add a new id and leave the old one readable.
- Unknown or malformed persisted ids are ignored during hydration for forward/backward compatibility.
- This state is local product state, not telemetry. Analytics should use the bounded feature-education telemetry events (`contextual_tour_shown` and `contextual_tour_outcome`) plus existing downstream action events, rather than reading or uploading the persisted state blob.
- Automatic contextual tours are new-user-only for this rollout. Do not reset `contextualToursAutoEligible` for existing users unless the product decision changes for a later release.
## Adding A Tip
When a tip should not show after the user has already used the feature, add the relevant interaction id to the tip definition:
```ts
{
id: 'voice-dictation',
completedByFeatureInteractions: ['voice-dictation']
}
```
Then make sure the feature records its interaction at the meaningful product moment. For example, voice dictation records only after a session reaches `listening`, not when the settings pane opens.
## Adding A Tour
Tours still use `contextualToursSeenIds` to avoid repeating the same tour. If opening or using the toured surface should also suppress future education for that feature, call `recordFeatureInteraction(featureId)` from the surface's meaningful interaction point.
For contextual tours in this branch, `useContextualTour(...)` records the matching interaction once the surface is enabled and persisted UI is ready. Surface-specific gates decide what "enabled" means; for example, the browser tour waits for a non-blank local browser page.
## Test Profiles
Completed-onboarding E2E profiles should preseed both education exposure and feature interaction state. That keeps first-run education from covering unrelated UI under test while preserving production behavior for real profiles.
Profiles that model existing users should also set `contextualToursAutoEligible: false`; profiles that explicitly exercise contextual tours can set it to `true`.
+62
View File
@@ -0,0 +1,62 @@
# Git Compatibility Policy
## Scope
Orca executes the user's Git binary on three kinds of execution host: native,
WSL, and SSH. Each host can have a different Git version, so compatibility
state must be scoped to the host that actually runs the command.
Git 2.25 is the core-workflow compatibility baseline for command selection. It
is the oldest line that covers Orca's baseline use of porcelain v2, `branch
--show-current`, `restore`, and sparse checkout. Optional features that need a
newer Git must degrade safely and cache the missing capability. Orca does not
currently block older Git at startup, but new command construction should not
assume features introduced after this baseline.
## Capability Rules
When a newer Git feature materially improves correctness or performance:
1. Keep a baseline-compatible command or parser as the fallback.
2. Detect rejection with a narrow predicate for that option or subcommand.
3. Run the preferred command through `GitCapabilityCache` so a rejection is
remembered for the native host, WSL distro, or SSH provider that produced it.
4. Retry after the cache interval so an in-place Git upgrade self-heals without
restarting Orca.
5. Test the first fallback, later calls that skip the rejected probe, concurrent
probe coalescing, and execution-host isolation where applicable.
Do not branch only on a parsed `git --version`. Vendor builds can backport
features, and wrappers can report a host version that differs from the binary
used inside WSL or SSH. A behavior probe plus a precise fallback is the final
authority.
## Current Capabilities
| Capability | Preferred behavior | Compatibility behavior |
| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `worktree-list-z` | NUL-delimited worktree paths | Line-block parser for Git before `worktree list -z` |
| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo |
| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca |
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form |
## Why Not `simple-git`
`simple-git` is a process wrapper around the installed Git binary. Its custom
options and `raw` API pass arguments through to Git, so it cannot make a newer
flag work on an older binary or choose Orca's semantic fallback automatically.
It provides version reporting and subprocess queueing, but Orca already needs
its own WSL/SSH routing, cancellation, tracing, redaction, process cleanup, and
bounded output handling. Replacing the runner would move—not remove—the
capability problem.
## CI Contract
PR checks run the capability contract against real Git 2.25.5, 2.38.1, and
2.49.1 binaries. This spans the core-workflow baseline, the transitional
`merge-tree --write-tree` behavior before `--merge-base`, and current Git.
Keep the unit tests alongside that matrix. They cover concurrent probes,
native/WSL/SSH/relay isolation, and error-stream shapes that a single real
binary invocation cannot exercise deterministically.
+183
View File
@@ -0,0 +1,183 @@
# Headless Linux Server
Use this guide when you want to run `orca serve` on a Linux machine without a
desktop session, such as an Ubuntu VPS or a remote build box.
`orca serve` starts the Orca runtime without opening the desktop window. On
Linux, the packaged AppImage still needs the libraries that Electron expects at
startup. Current Orca builds can start Xvfb automatically for `orca serve` when
no `DISPLAY` is set, but Xvfb must be installed first. When `DISPLAY` is set,
Orca uses that display instead of starting a competing Xvfb process.
## Ubuntu 22.04 Prerequisites
Install the AppImage runtime dependency and Xvfb:
```bash
sudo apt-get update
sudo apt-get install -y curl libfuse2 xvfb
```
Download and make the AppImage executable:
```bash
sudo mkdir -p /opt/orca
sudo curl -L https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage \
-o /opt/orca/orca-linux.AppImage
sudo chmod +x /opt/orca/orca-linux.AppImage
```
If `Xvfb` was installed somewhere other than `/usr/bin`, confirm systemd can
find it later:
```bash
command -v Xvfb
```
## Run In The Foreground
Start with a foreground run before creating a service:
```bash
LIBGL_ALWAYS_SOFTWARE=1 /opt/orca/orca-linux.AppImage serve --port 6768
```
For remote clients, pass the address they should use to reach this server. A
Tailscale address is usually the safest option for private servers:
```bash
LIBGL_ALWAYS_SOFTWARE=1 /opt/orca/orca-linux.AppImage serve \
--port 6768 \
--pairing-address 100.64.1.20
```
The command prints the runtime endpoint and pairing URL. Stop it with `Ctrl+C`.
## Systemd Service
Create a dedicated service user and install directory. Run the service as this
user instead of root so the AppImage can keep Chromium's sandbox enabled.
```bash
sudo useradd --system --create-home --shell /usr/sbin/nologin orca
sudo chown -R orca:orca /opt/orca
```
For most hosts, one `orca serve` service is enough because Orca starts Xvfb on
display `:99` when no display exists:
```ini
# /etc/systemd/system/orca-serve.service
[Unit]
Description=Orca runtime server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=orca
WorkingDirectory=/home/orca
Environment=LIBGL_ALWAYS_SOFTWARE=1
ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
Replace `100.64.1.20` with the LAN, Tailscale, tunnel, or public hostname that
clients should use.
Enable the service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now orca-serve.service
sudo journalctl -u orca-serve.service -f
```
## Managed Xvfb Service
If you prefer to own the virtual display lifecycle in systemd, run Xvfb as a
separate service and set `DISPLAY=:99` for Orca.
```ini
# /etc/systemd/system/orca-xvfb.service
[Unit]
Description=Virtual X display for Orca
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/Xvfb :99 -screen 0 1280x1024x24 -nolisten tcp
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
If `command -v Xvfb` returned a different path, update `ExecStart` to that
absolute path.
Then add the display dependency to the Orca service:
```ini
# /etc/systemd/system/orca-serve.service
[Unit]
Description=Orca runtime server
After=network-online.target orca-xvfb.service
Wants=network-online.target orca-xvfb.service
[Service]
Type=simple
User=orca
WorkingDirectory=/home/orca
Environment=DISPLAY=:99
Environment=LIBGL_ALWAYS_SOFTWARE=1
ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
Enable both units:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now orca-xvfb.service orca-serve.service
```
## CLI Install Note
On a headless host, you do not need to open the desktop UI just to run the
server. Invoke the AppImage directly:
```bash
/opt/orca/orca-linux.AppImage serve --help
```
If you later install the desktop CLI from Orca settings, use that CLI for normal
shell workflows. Keep the AppImage path in systemd so service restarts do not
depend on an interactive shell profile.
## Troubleshooting
- `dlopen(): error loading libfuse.so.2`: install `libfuse2`.
- `Missing X server or $DISPLAY`: install `xvfb`, or start the managed Xvfb
service and set `DISPLAY=:99`.
- `Xvfb not found`: confirm `command -v Xvfb` and use that absolute path in the
systemd unit.
- GPU or DRI warnings on a VPS: keep `LIBGL_ALWAYS_SOFTWARE=1` in the service
environment.
- Chromium sandbox errors: confirm the service is running as the non-root
`orca` user and that `/opt/orca` is readable by that user.
- Clients cannot connect: make sure `--pairing-address` is an address reachable
from the client, and make sure firewalls allow the selected `--port`.
- Diagnosing other missing libraries: extract the AppImage without launching it
with `./orca-linux.AppImage --appimage-extract`, then run
`ldd squashfs-root/orca` to list any shared libraries the host is missing.
@@ -0,0 +1,66 @@
# Keyboard Layout Shortcut Dispatch
## Problem
Keyboard shortcuts must follow the user's active keyboard layout. A shortcut like `Cmd+W`
means "Command plus the key that produces `w`", not "Command plus the physical key labeled
W on a US keyboard." Physical-position matching breaks Dvorak, Colemak, AZERTY, JIS, and
other non-US layouts, and it also makes user keybinding overrides impossible to reason about.
## Decision
Orca app shortcuts dispatch by logical key by default.
The shared keybinding registry in `src/shared/keybindings.ts` is the source of truth for
app commands, configurable commands, shortcut recording, labels, conflict detection, browser
guest forwarding, and terminal pane commands. Code handling a user-facing app command must
call `keybindingMatchesAction`, `keybindingMatchesInput`, or a policy function built on those
helpers.
Physical `KeyboardEvent.code` may only decide a shortcut when the key is layout-invariant or
the platform cannot provide a real logical key.
Allowed physical-code uses:
- Modifier key release tracking, such as left/right Control release for held `Ctrl+Tab`.
- Layout-invariant keys, such as arrows, Tab, Enter, Escape, Backspace, Delete, Insert,
PageUp, PageDown, and explicit numpad bindings.
- Dead, unidentified, or missing logical keys where `KeyboardEvent.key` cannot describe the
produced key.
- Terminal byte encoding where the intent is a physical terminal escape sequence rather than
an Orca command.
Disallowed physical-code uses:
- Letter shortcuts for app actions.
- Punctuation shortcuts for app actions when `KeyboardEvent.key` reports the produced
punctuation.
- Clipboard shortcuts that are exposed as app or terminal UI commands.
- Hardcoded undo/redo/new/close/copy/paste handling outside the shared registry.
## Terminal Boundary
Terminal handling has two different jobs:
1. Orca commands that act on terminal UI, such as copy selection, paste, search, clear,
pane focus, split, and close. These are app shortcuts and must be layout-aware.
2. Bytes sent to the shell, such as readline escapes and Option-as-Alt sequences. These may
use physical key positions when terminal compatibility requires it.
This boundary is intentional. It lets non-US layouts use Orca commands naturally while
preserving shell behavior where users expect physical terminal-control sequences.
## Regression Requirements
Shortcut tests must cover both directions of a non-QWERTY swap:
- The key that produces the configured logical character must match, even if its physical code
differs.
- The physical US key must not match when it produces a different logical character.
Tests must also cover intentional exceptions:
- Dead or missing key fallback.
- Shifted punctuation aliases.
- Numpad-specific bindings.
- Terminal byte-encoding paths that intentionally use physical codes.
@@ -0,0 +1,245 @@
# New User Parallel Work Telemetry
## Goal
Measure whether the new parallel-work tours and the eight-step `Getting started` flow improve new-user activation and retention.
The product question is not "did the tour render?" It is:
- Did users who were eligible for the new parallel-work education retain better at D1, D3, and D7?
- Did seeing or completing the tour increase the behaviors that the activation plan expects: terminal splits, two agents in one worktree, two worktrees within 72 hours, task/review source use, setup script configuration, notifications, and agent capability setup?
- Where do users leave the education flow: before the tour starts, during a tour, after skipping a tour, after opening `Getting started`, or partway through the eight setup steps?
This document implements the telemetry portion of [`new-user-parallel-work-activation.md`](./new-user-parallel-work-activation.md).
Validation mode: the current branch logs the would-be event name and payload to the console instead of sending these new feature-education events through product telemetry. Keep the logged payload shape identical to the event payloads below so reviewers can verify what would be emitted before product telemetry is re-enabled.
## Product Decisions
Question: Should Orca keep, revise, or disable the automatic `workspace-agent-sessions` tour for new users?
Decision owner/use: Product and growth review the retention and activation read after rollout. If exposed users retain and activate better than eligible-but-unexposed users, keep or expand the tour. If exposed users underperform or skip/cancel heavily, revise copy, timing, or gating.
Question: Which part of the education flow is weak?
Decision owner/use: If users usually stop on a specific tour step, revise that step or shorten the tour. If users complete the tour but do not progress through `Getting started`, improve the handoff. If users open `Getting started` but stall before the first two parallel-work steps, revise the checklist actions.
Dashboard: `New User Parallel Work Activation`
Action: Use the dashboard to decide whether to continue rollout, adjust the tour, reorder checklist steps, or add missing downstream action telemetry.
## Existing Telemetry To Reuse
Keep the existing contextual-tour events as the primary tour exposure and outcome contract:
- `contextual_tour_shown`
- `contextual_tour_outcome`
`contextual_tour_outcome` already carries:
- `tour_id`
- `source`
- `outcome`: `completed`, `skipped`, or `cancelled`
- `steps_seen`
- `total_steps`
For the current branch, `workspace-agent-sessions` is the activation tour. Its source should distinguish automatic first-workspace exposure from checklist-triggered education:
- `workspace_agent_sessions_visible` for automatic surface-driven tour exposure
- `setup_guide_parallel_work` for the explicit checklist `Try it out` path
Do not add one telemetry event per tour opportunity or step render. Opportunity checks are driven by local UI eligibility, retries, target visibility, modal state, and session guards; emitting every "not shown" reason would turn tour gating into passive eligibility telemetry. One shown event plus one outcome event keeps volume low and still answers whether users saw, skipped, cancelled, completed, and how far they got.
## Tour Depth Improvement
Add stable step-depth fields to `contextual_tour_outcome` so the analysis can tell whether a user reached the actual late steps, not only how many visible steps happened to render.
Additive schema fields:
- `furthest_step_index`: integer `1..8`
- `defined_step_count`: integer `1..8`
Keep `steps_seen` and `total_steps`:
- `steps_seen` remains the count of unique rendered visible steps.
- `total_steps` remains the visible-step denominator for the user session.
- `furthest_step_index` is the highest 1-based index from the tour definition reached by the user.
- `defined_step_count` is the number of steps defined for that tour, regardless of target skipping.
This handles target-skipping correctly. A user who sees tour steps 1, 4, and 5 should report `steps_seen: 3`, `total_steps: 3`, `furthest_step_index: 5`, and `defined_step_count: 5`.
## New Setup Guide Events
The eight `Getting started` steps need a small telemetry contract because they are not contextual-tour steps. They are durable activation milestones.
### `setup_guide_opened`
Emit once when the modal opens.
Payload:
- `source`: `sidebar`, `contextual_tour`, `settings`, `feature_wall`, `help_menu`, or `unknown`
- `initial_completed_count`: integer `0..8`
- `total_steps`: integer `8`
- `first_incomplete_step_id`: one of the eight setup step ids, or `none`
Use:
- Measures whether users reach the checklist after the tour.
- Establishes starting depth so existing progress does not look like checklist-driven progress.
### `setup_guide_closed`
Emit once when a shown setup guide closes or unmounts.
Payload:
- `source`: same value used by `setup_guide_opened`
- `outcome`: `completed`, `dismissed`, or `interrupted`
- `initial_completed_count`: integer `0..8`
- `final_completed_count`: integer `0..8`
- `total_steps`: integer `8`
- `active_step_id`: one of the eight setup step ids, or `none`
Use:
- Measures whether users treat the checklist as useful or dismiss it without progress.
- Gives a compact "how far through the 8 steps" read without uploading a progress snapshot on every render.
### `setup_guide_step_completed`
Emit once per setup step when that step first transitions from incomplete to complete while the setup guide is visible. Hidden/background progress is persisted locally as baseline state but not emitted, because async setup refresh cannot reliably distinguish old completions from new user action.
Payload:
- `step_id`: one of `two-worktrees`, `browser`, `notifications`, `default-agent`, `task-sources`, `setup-script`, `add-two-repos`, `agent-capabilities`
- `section_id`: `parallel-work` or `setup`
- `completed_count`: integer `1..8`
- `total_steps`: integer `8`
- `setup_guide_visible`: boolean
Use:
- Measures the in-guide depth through the eight steps without backfilling old setup state on launch.
- Lets retention analysis bucket users by `0`, `1`, `2`, `3-4`, `5-7`, and `8` completed setup steps.
Implementation detail: persist a local set of emitted setup-guide step ids, similar in spirit to `contextualToursSeenIds`, so inferred durable state does not re-emit on every launch. Do not upload that local set.
## Downstream Activation Events
Reuse existing downstream events whenever possible:
- `workspace_created` for worktree/workspace creation depth
- `agent_started` for agent activity and follow-up actions
- `settings_changed` for supported settings such as notifications
- setup script prompt events for setup-script configuration paths
- task/source-specific events where they already exist
Add action-specific downstream telemetry only when the retention question cannot be answered from existing events. The first likely gap is terminal splitting. The activation plan names first-session terminal split as a primary success criterion, but the branch currently records `terminal-pane-split` as local feature-interaction state, not product telemetry.
If no existing telemetry captures terminal split, add:
### `terminal_pane_split`
Emit when a split succeeds, capped to the first split for each `source` + `direction` pair per UTC day on the local profile. This keeps the event useful for activation cohorts without making heavy terminal users dominate telemetry volume.
Payload:
- `source`: `contextual_tour`, `keyboard`, `context_menu`, `command`, `unknown`
- `direction`: `vertical` or `horizontal`
Do not include pane ids, worktree ids, paths, commands, prompts, repo names, branch names, hostnames, or terminal content.
## Retention Analysis
Use new-user eligibility as the least-biased baseline:
1. Eligible baseline: new-user cohorts represented by first-run/onboarding events rather than uploading local tour eligibility state.
2. Exposed cohort: users with `contextual_tour_shown` where `tour_id = workspace-agent-sessions`.
3. Tour outcome cohorts: users with `contextual_tour_outcome` broken down by `outcome`.
4. Tour depth cohorts: `furthest_step_index / defined_step_count` and `steps_seen / total_steps`.
5. Checklist depth cohorts: latest `setup_guide_step_completed.completed_count`, bucketed as `0`, `1`, `2`, `3-4`, `5-7`, and `8`.
Primary retention read:
- D1, D3, and D7 return to `app_opened`
- Compare new-user baseline, shown, completed, skipped, cancelled, and checklist-depth cohorts
Primary activation read:
- terminal split in first workspace session
- two agent sessions in one worktree
- two worktrees within 72 hours
- follow-up/manual agent action
- task/review source use
- setup script configured
- notifications configured
- agent capabilities completed
Interpretation rules:
- A completed tour with no downstream activation means the tour is educational but not converting.
- A skipped tour with strong downstream activation means the feature may be discoverable without the tour.
- A high cancelled rate usually points to target loss, modal conflicts, or timing problems.
- Strong checklist-depth retention with weak tour retention means the checklist is the value driver, not the automatic tour.
## Volume Estimate
Telemetry volume estimate:
- Trigger: contextual tour shown/outcome, setup guide open/close, setup step first completion, optional terminal split
- Expected events/user/day: 2-6 for new users who encounter the flow
- Max events/user/day: about 14 on a heavy first day: 2 tour events, 2 setup guide events, 8 setup step completions, 1-2 split events
- At 1,000 DAU: expected 2,000-6,000 events/day
- Monthly at 1,000 DAU: expected 60,000-180,000 events/month
- Approval note: The events answer the rollout decision directly and are bounded by explicit user actions or one-time durable setup completions. There are no timers, render-loop events, polling snapshots, or passive heartbeats.
If observed volume exceeds expectation, remove or sample `setup_guide_opened` / `setup_guide_closed` before removing completion events. Completion events are the durable activation-depth read.
## Privacy
All payloads must be low-cardinality and schema-bounded.
Never include:
- prompts
- terminal commands
- paths
- URLs
- hostnames
- repo names
- branch names
- task titles
- user-authored text
- raw errors
- tokens
- pane ids, worktree ids, repo ids, or other persistent product identifiers
## Implementation Plan
1. Extend `contextual_tour_outcome` schema in `src/shared/telemetry-events.ts` with optional `furthest_step_index` and `defined_step_count`.
2. Track the highest reached defined tour step in `ContextualTourOverlay.tsx` alongside the existing `steps_seen` set.
3. Add setup-guide event schemas and bounded enums in `src/shared/telemetry-events.ts`.
4. Add a setup-guide telemetry helper in `src/renderer/src/lib/feature-education-telemetry.ts` or a dedicated setup-guide telemetry module.
5. Emit `setup_guide_opened` and `setup_guide_closed` from `SetupGuideModal.tsx`.
6. Emit `setup_guide_step_completed` from a small hook that observes visible setup-guide progress transitions, persists emitted step ids locally, and ignores hidden/background completions except as local baseline state.
7. Add `terminal_pane_split` only if no existing telemetry captures successful terminal splits.
8. Add schema tests for accepted payloads, rejected raw strings, count bounds, and enum bounds.
9. Add renderer tests that prove each modal open emits one open event, each close emits one close event, each step completion emits once, rerenders do not re-emit, and pre-completed persisted steps are not backfilled repeatedly.
## Dashboard Tiles
Create a PostHog dashboard named `New User Parallel Work Activation`.
Tiles:
- Tour exposure: `contextual_tour_shown` by `tour_id` and `source`
- Tour outcome: `contextual_tour_outcome` by `outcome`
- Tour depth: median `furthest_step_index / defined_step_count`
- Setup guide reach: `setup_guide_opened` by `source`
- Setup guide close outcome: `setup_guide_closed` by `outcome`
- Eight-step depth: latest `setup_guide_step_completed.completed_count` bucket
- Activation funnel: tour shown -> tour outcome -> setup guide opened -> first two setup steps complete -> D1/D3/D7 return
- Downstream actions: tour shown -> terminal split -> two agents in one worktree -> two worktrees within 72 hours
Do not publish dashboard tiles that require raw user content or identifiers.
@@ -0,0 +1,105 @@
# OpenCode Commit Message Stdin Delivery
## Problem
OpenCode's preset Source Control AI path passes the full generated prompt as the final `opencode run` argv positional. Commit-message prompts include branch, staged file summary, user instructions, and the staged patch. The patch is capped by `STAGED_DIFF_BYTE_BUDGET = 200_000`, but the implementation uses JavaScript string length, not encoded byte length, so argv can still be much larger than command-line limits on Windows and some POSIX/SSH wrapper paths before OpenCode starts.
Verified current code:
- `src/shared/commit-message-agent-spec.ts:24-32` defines `promptDelivery` as the argv/stdin contract for large diffs.
- `src/shared/commit-message-agent-spec.ts:322-337` sets OpenCode to `promptDelivery: 'argv'` and appends `prompt`.
- `src/shared/commit-message-plan.ts:108-123` already routes `promptDelivery === 'stdin'` into `stdinPayload`.
- `src/main/text-generation/commit-message-text-generation.ts:470-601` pipes local `stdinPayload` to child stdin.
- `src/main/providers/ssh-git-provider.ts:122-140` forwards `stdinPayload` as relay `stdin`; `src/relay/agent-exec-handler.ts:160-303` writes it to the remote child.
- `src/shared/commit-message-plan.test.ts:36` currently locks in the broken OpenCode argv behavior.
## Feasibility Gate
`opencode --version` on the local machine reports `1.15.13`. `opencode run --help` confirms the required args exist: `--model`, `--agent`, `--format`, and `--variant`, with optional `[message..]` positionals. It does not document stdin prompt input.
Shipping gate: before changing the preset, run a controlled smoke test using the exact no-message shape, and verify stdout clearly reflects the stdin prompt rather than an empty task:
```sh
printf '%s\n' 'Write: test commit' | opencode run --model <model> --agent build --format default
```
Also smoke `--variant <level>` if the selected model exposes thinking levels. If credentials, model access, or an unsupported OpenCode version block validation, do not treat `--help` alone as proof. If stdin fails, do not add an argv fallback; stop and choose a different no-argv transport or version-gated behavior.
## Root Cause
The planner and both local/SSH execution paths already know how to carry stdin payloads. The mismatch is the OpenCode preset args.
OpenCode is not the only argv-delivered path in the registry: Cursor and custom commands that explicitly use `{prompt}` still put prompts in argv. They are out of scope here and should not be described as fixed by this change.
## Non-Goals
- Do not change prompt wording, diff truncation, output cleanup, model discovery, or model selection.
- Do not change interactive OpenCode terminal launch behavior.
- Do not change GitHub/GitLab/provider review flows.
- Do not change Cursor argv delivery or custom command `{prompt}` semantics.
- Do not add a second OpenCode execution path.
## Design
1. Change the OpenCode preset spec to `promptDelivery: 'stdin'`.
2. Add a short why-comment near that setting: OpenCode prompt input can contain large staged diffs and must stay out of argv.
3. Remove `prompt` from OpenCode `buildArgs`. The preset args must be exactly `run --model <model> --agent build --format default`, plus `--variant <thinkingLevel>` when present.
4. Keep `modelSource`, `modelDiscovery`, default model, and dynamic-model fallback behavior unchanged.
5. Keep preset `agentCommandOverride` behavior unchanged: an override such as `npx opencode` prefixes the same OpenCode args, while the prompt remains `stdinPayload`.
6. Accept the shared-spec blast radius: OpenCode prompts for commit-message, PR-field, and branch-name generation all move to stdin because they all call `planCommitMessageGeneration(...)`. If the requirement is commit-message-only, this design is wrong and needs operation-aware delivery instead.
## Data Flow
- Source Control AI receives a staged context and builds the prompt. The context is not atomic: branch, staged summary, and staged patch are read by separate git commands before generation.
- `planCommitMessageGeneration(...)` validates the agent, model, and thinking level against the existing dynamic/static model rules.
- Because OpenCode becomes stdin-delivered, the planner passes an empty prompt into `buildArgs` and stores the real prompt in `plan.stdinPayload`.
- Local generation writes `stdinPayload` to the child process stdin.
- SSH generation serializes the same plan to the relay as `{ stdin: plan.stdinPayload }`, and the relay writes it to the remote child process stdin after spawn.
This fix removes the prompt from process argv only. It does not reduce prompt memory, model latency, token use, or diff truncation pressure. Remote execution still sends the prompt in one JSON-RPC frame; the relay frame cap is 16 MB, and today's prompt budget is under that, but future budget increases must account for it.
## Edge Cases
- Large staged diff: the prompt must be absent from `args`, including no empty-string placeholder.
- Thinking level selected: preserve `--variant <level>`.
- No thinking level selected: omit `--variant`.
- Dynamic OpenCode model not present in the seed catalog: preserve the existing dynamic fallback and still pass the selected model id.
- Agent command override: `npx opencode` becomes `binary: 'npx'`, `args: ['opencode', 'run', ...]`, with prompt in `stdinPayload`.
- Windows batch shims: removing the prompt from argv also removes prompt metacharacters from the batch command line; do not route the prompt through `{prompt}` for this preset.
- SSH: the prompt is JSON-serialized as `stdin`, not shell-quoted, and is written by the relay after spawn.
- SSH payload size: this is safe for the current prompt budget, not an unbounded transport. Anything near 16 MB needs streaming or another transport.
- OpenCode ignores stdin: the smoke test must catch this before shipping because `--help` does not document stdin.
- Output cleanup remains unchanged; OpenCode stderr/status chatter should be handled by existing failure and cleanup logic.
## Consistency And Concurrency
- No cache invalidation is introduced. Each generation plans from the request's current settings and the staged context supplied to that request.
- The staged context is not a true snapshot. If the index changes between the summary and patch reads, or after the context is read, the generated message can describe a mixed or stale view. That is existing behavior and not fixed here.
- Multiple windows can request generation for the same worktree. Local execution does not serialize same-`cwd` requests; the cancel map is keyed by `operation:local:<cwd>` and the newest request overwrites the reachable cancel token. Do not claim this change fixes local multi-window races.
- SSH execution queues matching `operation + cwd` lanes inside a `SshGitProvider` instance. The relay itself tracks only one active child per lane and cancels a prior active child if another request for the same lane reaches it, which matters if separate provider instances/windows bypass the same queue.
- Settings or command overrides changed while a generation is in flight affect the next request only; the current child keeps its immutable plan.
## Test Plan
- `src/shared/commit-message-agent-spec.test.ts`: add OpenCode coverage asserting `promptDelivery === 'stdin'`, no prompt or empty placeholder in `buildArgs`, variant preservation, and no variant when absent.
- `src/shared/commit-message-plan.test.ts`: update OpenCode expectations so `stdinPayload` receives the prompt and argv excludes it; include an OpenCode command-override case.
- Because `planCommitMessageGeneration(...)` is reused by commit-message, PR-field, and branch-name generation, the plan tests are the main regression coverage for the shared blast radius.
- Keep existing Codex stdin, custom-command stdin, local child stdin, SSH provider, and relay stdin tests passing.
- Run targeted Vitest for the touched shared tests, plus `pnpm typecheck` and `pnpm lint` for the implementation branch.
- Run the OpenCode stdin smoke test from the feasibility gate. If it cannot run, do not mark implementation risk as resolved.
## Lightweight Eng Review
- Scope: shared preset-agent plumbing and shared tests, with an explicit shared-operation blast radius for OpenCode commit-message, PR-field, and branch-name prompts. No renderer redesign, settings migration, hosted review flow, or provider-specific review behavior.
- Architecture/data flow: use the existing `promptDelivery` contract; local and SSH execution already honor `stdinPayload`.
- Failure modes covered: prompt re-enters argv, `--variant` is dropped, unknown dynamic model ids regress, command overrides change stdin behavior, remote JSON-RPC frame limits are ignored, or OpenCode accepts the flags but ignores stdin.
- Performance/blast radius: no renderer, watcher, IPC subscription, or startup cost. Local argv shrinks; remote payload size is unchanged because the prompt still crosses JSON-RPC as stdin data in one frame.
- UI quality bar: no visual change; skip visual-design review.
- Screenshots: none required for this headless invocation change. If PR evidence is needed, prefer unit-test output and the controlled OpenCode stdin smoke result.
- Residual risk: OpenCode stdin prompt input is not documented by `opencode run --help`; remote hosts may have different OpenCode versions; existing local multi-window cancellation races and staged-context non-atomicity remain.
## Rollout
1. Update OpenCode spec delivery and args.
2. Update shared spec and planner tests.
3. Run targeted tests, typecheck, lint, and the OpenCode smoke test before shipping.
@@ -0,0 +1,972 @@
# Manual Network Address Entry — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users type a custom IPv4 address or Tailscale MagicDNS hostname into the desktop mobile-pairing Network Interface dropdown, so the QR code can target a host the OS hasn't auto-discovered.
**Architecture:** Replace the inner `Select` of `MobileNetworkInterfaceSection` with a `Popover + Command` ("combobox") pattern modeled on the existing `AgentCombobox`. A new shared pure function `parseManualNetworkAddress` enforces the input grammar; a new helper `buildComboboxEntries` builds the row list (filtered interfaces + an optional `Use "<query>"` row). The custom selection is session-scoped.
**Tech Stack:** TypeScript, React 18, shadcn/ui primitives (`Command`, `Popover`, `Button`) from `src/renderer/src/components/ui/`, vitest, `@testing-library/react`. No new dependencies.
**Spec:** [`docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md`](../2026-06-27-orca-mobile-manual-network-address-design.md)
---
## File map
**Create:**
- `src/shared/network/manual-address.ts` — pure parser.
- `src/shared/network/manual-address.test.ts` — parser unit tests.
- `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx` — UI render tests.
**Modify:**
- `src/renderer/src/components/settings/mobile-network-interface-selection.ts` — swap `mergeForSelect` for `buildComboboxEntries` (or add `buildComboboxEntries` and keep the old one if other call sites exist; confirm with grep before deleting).
- `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts` — replace `mergeForSelect` tests with `buildComboboxEntries` tests.
- `src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` — swap `Select` for `Popover + Command`.
**Reference (read-only, do not modify):**
- `src/renderer/src/components/ui/command.tsx``Command`, `CommandEmpty`, `CommandInput`, `CommandItem`, `CommandList`, `CommandSeparator`.
- `src/renderer/src/components/ui/popover.tsx``Popover`, `PopoverTrigger`, `PopoverContent`.
- `src/renderer/src/components/ui/button.tsx``Button`.
- `src/renderer/src/components/agent/AgentCombobox.tsx` — full reference implementation of the controlled cmdk pattern.
- `src/renderer/src/components/agent/agent-combobox-command-state.ts``createAgentComboboxCommandState`, `resolveAgentComboboxCommandState`, `updateAgentComboboxCommandValue`.
- `src/renderer/src/components/settings/MobilePairingQrSection.tsx` — consumer of `selectedAddress`; unchanged.
**Out of scope:** mobile-side endpoint override (`mobile/app/pair-scan.tsx`); main-process code; persistent storage of custom addresses; IPv6, ports, non-Tailscale hostnames.
---
## Task 1: Add `parseManualNetworkAddress` (TDD)
**Files:**
- Create: `src/shared/network/manual-address.ts`
- Create: `src/shared/network/manual-address.test.ts`
- [ ] **Step 1: Confirm no existing call site for `mergeForSelect`**
Run:
```bash
grep -rn "mergeForSelect" src/ docs/ 2>/dev/null
```
Expected: no matches. (If there are matches, stop and update Task 2 to keep `mergeForSelect` and only add `buildComboboxEntries` alongside it.)
- [ ] **Step 2: Write the failing tests**
Create `src/shared/network/manual-address.test.ts` with this exact content:
```ts
import { describe, it, expect } from 'vitest'
import { parseManualNetworkAddress } from './manual-address'
describe('parseManualNetworkAddress', () => {
describe('IPv4', () => {
it('accepts canonical IPv4', () => {
expect(parseManualNetworkAddress('192.168.1.24')).toEqual({
ok: true,
address: '192.168.1.24'
})
expect(parseManualNetworkAddress('100.64.1.20')).toEqual({
ok: true,
address: '100.64.1.20'
})
})
it('accepts boundary IPv4 values', () => {
expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true)
expect(parseManualNetworkAddress('255.255.255.255').ok).toBe(true)
})
it('rejects malformed IPv4', () => {
for (const bad of ['', ' ', '1.2.3', '1.2.3.4.5', '256.0.0.1']) {
expect(parseManualNetworkAddress(bad)).toEqual({
ok: false,
error: 'Enter an IPv4 address or Tailscale MagicDNS hostname'
})
}
})
it('rejects leading zeros in octets', () => {
expect(parseManualNetworkAddress('01.02.03.04')).toEqual({
ok: false,
error: 'Enter an IPv4 address or Tailscale MagicDNS hostname'
})
expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true)
})
})
describe('Tailscale MagicDNS hostname', () => {
it('accepts short MagicDNS names', () => {
expect(parseManualNetworkAddress('my-mac.ts.net')).toEqual({
ok: true,
address: 'my-mac.ts.net'
})
})
it('accepts tailnet-qualified MagicDNS names', () => {
expect(parseManualNetworkAddress('my-mac.tail-abcd.ts.net')).toEqual({
ok: true,
address: 'my-mac.tail-abcd.ts.net'
})
expect(parseManualNetworkAddress('a.b.c.d.ts.net').ok).toBe(true)
})
it('is case-insensitive', () => {
expect(parseManualNetworkAddress('MY-MAC.TS.NET').ok).toBe(true)
})
it('rejects non-Tailscale hostnames', () => {
for (const bad of ['my-mac', 'my-mac.ts.com', '-foo.ts.net', 'my-mac.com']) {
expect(parseManualNetworkAddress(bad).ok).toBe(false)
}
})
})
describe('length and whitespace', () => {
it('rejects inputs longer than 253 chars', () => {
const long = `${'a'.repeat(250)}.ts.net`
expect(long.length).toBeGreaterThan(253)
expect(parseManualNetworkAddress(long).ok).toBe(false)
})
it('trims leading and trailing whitespace before validating', () => {
expect(parseManualNetworkAddress(' 192.168.1.24 ')).toEqual({
ok: true,
address: '192.168.1.24'
})
})
})
})
```
- [ ] **Step 3: Run tests to verify they fail**
Run from repo root:
```bash
pnpm vitest run src/shared/network/manual-address.test.ts
```
Expected: error like `Cannot find module './manual-address'`. This is the failing-test step — do not skip it.
- [ ] **Step 4: Implement `parseManualNetworkAddress`**
Create `src/shared/network/manual-address.ts` with this exact content:
```ts
// Why: pure shared helper so the same validation runs in renderer
// today and in any future CLI/main-process caller without duplicating
// the IPv4 + Tailscale MagicDNS grammar.
const IPV4_OCTET = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])'
const IPV4 = `(?:${IPV4_OCTET}\\.){3}${IPV4_OCTET}`
// MagicDNS hostname: lowercase letters/digits/hyphens, dot-separated, ending in .ts.net.
// Labels may not start or end with a hyphen; max 63 chars per label (DNS limit).
const MAGICDNS_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?'
const MAGICDNS = `(?:${MAGICDNS_LABEL}\\.)+ts\\.net`
const HOSTNAME_MAX_LENGTH = 253
const ERROR_MESSAGE = 'Enter an IPv4 address or Tailscale MagicDNS hostname'
export type ParseManualAddressResult =
| { ok: true; address: string }
| { ok: false; error: string }
export function parseManualNetworkAddress(input: string): ParseManualAddressResult {
const trimmed = input.trim()
if (trimmed === '' || trimmed.length > HOSTNAME_MAX_LENGTH) {
return { ok: false, error: ERROR_MESSAGE }
}
if (/\s/.test(trimmed)) {
return { ok: false, error: ERROR_MESSAGE }
}
const ipv4Regex = new RegExp(`^${IPV4}$`)
if (ipv4Regex.test(trimmed)) {
return { ok: true, address: trimmed }
}
const magicRegex = new RegExp(`^(?:${MAGICDNS})$`, 'i')
if (magicRegex.test(trimmed)) {
return { ok: true, address: trimmed }
}
return { ok: false, error: ERROR_MESSAGE }
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run:
```bash
pnpm vitest run src/shared/network/manual-address.test.ts
```
Expected: all tests pass.
- [ ] **Step 6: Lint and typecheck**
```bash
pnpm lint src/shared/network/
pnpm typecheck
```
Expected: no errors. Fix any and re-run before continuing.
- [ ] **Step 7: Commit**
```bash
git add src/shared/network/manual-address.ts src/shared/network/manual-address.test.ts
git commit -m "feat(mobile-pairing): add parseManualNetworkAddress validator"
```
---
## Task 2: Replace `mergeForSelect` with `buildComboboxEntries` (TDD)
**Files:**
- Modify: `src/renderer/src/components/settings/mobile-network-interface-selection.ts`
- Modify: `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts`
- [ ] **Step 1: Read the current contents of both files**
Read the full source of:
- `src/renderer/src/components/settings/mobile-network-interface-selection.ts`
- `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts`
Confirm: `mergeForSelect` is the only exported function besides the `MobileNetworkInterface` type and `selectRefreshedNetworkAddress`. If `selectRefreshedNetworkAddress` is called from `MobileNetworkInterfaceSection.tsx`, keep it.
- [ ] **Step 2: Write the failing tests for `buildComboboxEntries`**
Replace the entire body of `mobile-network-interface-selection.test.ts` with:
```ts
import { describe, it, expect } from 'vitest'
import {
buildComboboxEntries,
selectRefreshedNetworkAddress,
type MobileNetworkInterface
} from './mobile-network-interface-selection'
const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' }
const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' }
describe('buildComboboxEntries', () => {
it('returns only interface entries when query is empty', () => {
const entries = buildComboboxEntries([LAN, TAILNET], '')
expect(entries).toEqual([
{ kind: 'interface', iface: LAN },
{ kind: 'interface', iface: TAILNET }
])
})
it('filters interfaces by substring on address or name (case-insensitive)', () => {
const entries = buildComboboxEntries([LAN, TAILNET], 'TAIL')
expect(entries).toEqual([{ kind: 'interface', iface: TAILNET }])
})
it('appends a use-query entry when the query is a valid address not in the list', () => {
const entries = buildComboboxEntries([LAN, TAILNET], 'my-mac.tail-abcd.ts.net')
expect(entries).toEqual([
{ kind: 'interface', iface: LAN },
{ kind: 'interface', iface: TAILNET },
{ kind: 'use-query', address: 'my-mac.tail-abcd.ts.net' }
])
})
it('suppresses use-query when query equals an existing interface address', () => {
const entries = buildComboboxEntries([LAN, TAILNET], '100.64.1.20')
expect(entries).toEqual([
{ kind: 'interface', iface: LAN },
{ kind: 'interface', iface: TAILNET }
])
})
it('returns only use-query when interfaces are empty and query is valid', () => {
const entries = buildComboboxEntries([], '1.2.3.4')
expect(entries).toEqual([{ kind: 'use-query', address: '1.2.3.4' }])
})
it('omits use-query when query is invalid', () => {
const entries = buildComboboxEntries([LAN, TAILNET], 'not-an-address')
expect(entries).toEqual([
{ kind: 'interface', iface: LAN },
{ kind: 'interface', iface: TAILNET }
])
})
})
describe('selectRefreshedNetworkAddress', () => {
// Existing behavior is preserved verbatim from the spec.
it('keeps the selected address when refresh discovers a new tailnet interface', () => {
expect(selectRefreshedNetworkAddress(LAN.address, [LAN, TAILNET])).toBe(LAN.address)
})
it('selects the first refreshed interface when there is no current address', () => {
expect(selectRefreshedNetworkAddress(undefined, [TAILNET, LAN])).toBe(TAILNET.address)
})
it('prefers a tailnet address when no address is selected yet', () => {
expect(selectRefreshedNetworkAddress(undefined, [LAN, TAILNET])).toBe(TAILNET.address)
})
it('moves to the first refreshed interface when the current address disappeared', () => {
expect(selectRefreshedNetworkAddress('10.0.0.4', [TAILNET, LAN])).toBe(TAILNET.address)
})
it('moves to a tailnet address when the current address disappeared', () => {
expect(selectRefreshedNetworkAddress('10.0.0.4', [LAN, TAILNET])).toBe(TAILNET.address)
})
it('clears the selection when no interfaces are available', () => {
expect(selectRefreshedNetworkAddress(LAN.address, [])).toBeUndefined()
})
})
```
- [ ] **Step 3: Run the test file to verify the new tests fail**
```bash
pnpm vitest run src/renderer/src/components/settings/mobile-network-interface-selection.test.ts
```
Expected: `buildComboboxEntries is not a function` (or import error) for the new block; the `selectRefreshedNetworkAddress` block still passes.
- [ ] **Step 4: Rewrite `mobile-network-interface-selection.ts`**
Replace the entire file with:
```ts
import { isTailnetIPv4Address } from '../../../../shared/tailnet-address'
import { parseManualNetworkAddress } from '../../../../shared/network/manual-address'
export type MobileNetworkInterface = {
name: string
address: string
}
export type ComboboxEntry =
| { kind: 'interface'; iface: MobileNetworkInterface }
| { kind: 'use-query'; address: string }
// Why: the UI needs a single ordered list to render inside CommandList.
// Behavior branches on whether the query parses as a valid address —
// valid queries show the full interface list (so users can pivot to an
// existing interface mid-typing), invalid queries substring-filter and
// fall back to the full list when nothing matches.
export function buildComboboxEntries(
interfaces: readonly MobileNetworkInterface[],
query: string
): readonly ComboboxEntry[] {
const trimmed = query.trim()
if (trimmed === '') {
return interfaces.map((iface) => ({ kind: 'interface' as const, iface }))
}
const parsed = parseManualNetworkAddress(trimmed)
let visible: readonly MobileNetworkInterface[]
if (parsed.ok) {
// Valid address: keep every interface visible.
visible = interfaces
} else {
// Invalid: substring-filter; fall back to full list when nothing matches.
const lowered = trimmed.toLowerCase()
const filtered = interfaces.filter(
(iface) =>
iface.address.toLowerCase().includes(lowered) ||
iface.name.toLowerCase().includes(lowered)
)
visible = filtered.length > 0 ? filtered : interfaces
}
const entries: ComboboxEntry[] = visible.map((iface) => ({
kind: 'interface' as const,
iface
}))
if (parsed.ok && !visible.some((iface) => iface.address === parsed.address)) {
entries.push({ kind: 'use-query', address: parsed.address })
}
return entries
}
export function selectRefreshedNetworkAddress(
currentAddress: string | undefined,
interfaces: readonly MobileNetworkInterface[]
): string | undefined {
if (interfaces.length === 0) {
return undefined
}
if (currentAddress && interfaces.some((iface) => iface.address === currentAddress)) {
return currentAddress
}
return (
interfaces.find((iface) => isTailnetIPv4Address(iface.address))?.address ??
interfaces[0]!.address
)
}
```
The relative import paths (`../../../../shared/tailnet-address`, `../../../../shared/network/manual-address`) must match the file's location in the tree. If they don't resolve, adjust based on the existing path style in the same directory (e.g. `../foo` vs `@/foo`).
- [ ] **Step 5: Run the test file to verify everything passes**
```bash
pnpm vitest run src/renderer/src/components/settings/mobile-network-interface-selection.test.ts
```
Expected: all tests pass.
- [ ] **Step 6: Lint and typecheck**
```bash
pnpm lint src/renderer/src/components/settings/mobile-network-interface-selection.ts
pnpm typecheck
```
- [ ] **Step 7: Commit**
```bash
git add src/renderer/src/components/settings/mobile-network-interface-selection.ts \
src/renderer/src/components/settings/mobile-network-interface-selection.test.ts
git commit -m "feat(mobile-pairing): buildComboboxEntries for network interface combobox"
```
---
## Task 3: Rewrite `MobileNetworkInterfaceSection` UI to use Popover + Command
**Files:**
- Modify: `src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx`
- Create: `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx`
- [ ] **Step 1: Read `AgentCombobox.tsx` end-to-end**
Read `src/renderer/src/components/agent/AgentCombobox.tsx` and `agent-combobox-command-state.ts` in full. You will be borrowing:
- The `Popover` + `Command` + `CommandInput` + `CommandList` + `CommandItem` JSX structure.
- The controlled `commandState`/`commandValue` pattern that mirrors `AgentCombobox`.
- The `setInputNode`/`focusSearchInput` pattern for keyboard accessibility.
Do not import agent-specific helpers (e.g. `searchAgentPickerEntries`, `agent-combobox-command-state` is shared but the search logic isn't needed here).
- [ ] **Step 2: Read the current `MobileNetworkInterfaceSection.tsx` in full**
Read the file. Note the props contract:
```ts
type MobileNetworkInterfaceSectionProps = {
networkInterfaces: MobileNetworkInterface[]
selectedAddress: string | undefined
onSelectedAddressChange: (address: string) => void
refreshingNetworkInterfaces: boolean
onRefreshNetworkInterfaces: () => void
loading: boolean
hasQrCode: boolean
onGenerateQr: () => void
}
```
The contract **must not change** — only the inner control does. `MobilePairingQrSection.tsx` consumes `selectedAddress` and is unaffected.
- [ ] **Step 3: Write the failing render tests**
Create `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx`:
```tsx
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection'
import type { MobileNetworkInterface } from './mobile-network-interface-selection'
const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' }
const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' }
function renderSection(overrides: Partial<React.ComponentProps<typeof MobileNetworkInterfaceSection>> = {}) {
const onSelectedAddressChange = vi.fn()
const onRefreshNetworkInterfaces = vi.fn()
const onGenerateQr = vi.fn()
const props: React.ComponentProps<typeof MobileNetworkInterfaceSection> = {
networkInterfaces: [LAN, TAILNET],
selectedAddress: TAILNET.address,
onSelectedAddressChange,
refreshingNetworkInterfaces: false,
onRefreshNetworkInterfaces,
loading: false,
hasQrCode: false,
onGenerateQr,
...overrides
}
const user = userEvent.setup()
const utils = render(<MobileNetworkInterfaceSection {...props} />)
return { ...utils, user, onSelectedAddressChange, onRefreshNetworkInterfaces, onGenerateQr }
}
describe('MobileNetworkInterfaceSection', () => {
it('renders the trigger with the currently selected address', () => {
renderSection()
expect(screen.getByRole('combobox')).toHaveTextContent('100.64.1.20 (tailscale0)')
})
it('lets the user type a custom address and confirms via the Use row', async () => {
const { user, onSelectedAddressChange } = renderSection()
await user.click(screen.getByRole('combobox'))
const input = screen.getByPlaceholderText(/search or type/i)
await user.type(input, 'my-mac.tail-abcd.ts.net')
await user.click(screen.getByRole('option', { name: /Use "my-mac\.tail-abcd\.ts\.net"/ }))
expect(onSelectedAddressChange).toHaveBeenCalledWith('my-mac.tail-abcd.ts.net')
})
it('shows an inline error and no Use row when the query is invalid', async () => {
const { user } = renderSection()
await user.click(screen.getByRole('combobox'))
await user.type(screen.getByPlaceholderText(/search or type/i), 'not an address')
expect(screen.getByText(/Enter an IPv4 address or Tailscale MagicDNS hostname/i)).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /Use / })).not.toBeInTheDocument()
})
it('suppresses the Use row when the typed address matches an existing interface', async () => {
const { user } = renderSection()
await user.click(screen.getByRole('combobox'))
await user.type(screen.getByPlaceholderText(/search or type/i), '192.168.1.24')
expect(screen.getByRole('option', { name: '192.168.1.24 (en0)' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /Use "192\.168\.1\.24"/ })).not.toBeInTheDocument()
})
it('renders the (custom) label on the trigger after a custom selection', () => {
renderSection({ selectedAddress: 'my-mac.tail-abcd.ts.net' })
expect(screen.getByRole('combobox')).toHaveTextContent('my-mac.tail-abcd.ts.net (custom)')
})
it('shows No interfaces found when the list is empty', () => {
renderSection({ networkInterfaces: [], selectedAddress: undefined })
expect(screen.getByRole('combobox')).toHaveTextContent(/no interfaces found/i)
})
})
```
- [ ] **Step 4: Run the test file to verify it fails**
```bash
pnpm vitest run src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx
```
Expected: failures on every assertion — the component still uses `Select`. This is the failing-test step.
- [ ] **Step 5: Rewrite the component**
Replace the entire body of `MobileNetworkInterfaceSection.tsx` with:
```tsx
import { useCallback, useMemo, useState } from 'react'
import { ChevronDown, ExternalLink, Loader2, QrCode, RefreshCw, Wifi } from 'lucide-react'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from '../ui/accordion'
import { Button } from '../ui/button'
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
} from '../ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { parseManualNetworkAddress } from '../../../../shared/network/manual-address'
import { translate } from '@/i18n/i18n'
import {
buildComboboxEntries,
type MobileNetworkInterface
} from './mobile-network-interface-selection'
const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download'
const TRIGGER_LABEL_CUSTOM = 'custom'
const ERROR_MESSAGE = 'Enter an IPv4 address or Tailscale MagicDNS hostname'
type MobileNetworkInterfaceSectionProps = {
networkInterfaces: MobileNetworkInterface[]
selectedAddress: string | undefined
onSelectedAddressChange: (address: string) => void
refreshingNetworkInterfaces: boolean
onRefreshNetworkInterfaces: () => void
loading: boolean
hasQrCode: boolean
onGenerateQr: () => void
}
function formatInterfaceLabel(iface: MobileNetworkInterface): string {
return `${iface.address} (${iface.name})`
}
function isCustomLabel(name: string): boolean {
return name === TRIGGER_LABEL_CUSTOM
}
export function MobileNetworkInterfaceSection({
networkInterfaces,
selectedAddress,
onSelectedAddressChange,
refreshingNetworkInterfaces,
onRefreshNetworkInterfaces,
loading,
hasQrCode,
onGenerateQr
}: MobileNetworkInterfaceSectionProps): React.JSX.Element {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
// Why: session-scoped custom selection (see spec). Cleared automatically
// when the settings pane unmounts because the state lives in this component.
const [customAddress, setCustomAddress] = useState<string | null>(null)
const selectedIface = useMemo<MobileNetworkInterface | null>(() => {
if (!selectedAddress) return null
const matched = networkInterfaces.find((iface) => iface.address === selectedAddress)
if (matched) return matched
if (selectedAddress === customAddress) {
return { name: TRIGGER_LABEL_CUSTOM, address: selectedAddress }
}
return null
}, [networkInterfaces, selectedAddress, customAddress])
const triggerLabel = selectedIface
? formatInterfaceLabel(selectedIface)
: translate(
'auto.components.settings.MobileNetworkInterfaceSection.b2c384cfd6',
'No interfaces found'
)
const entries = useMemo(
() => buildComboboxEntries(networkInterfaces, query),
[networkInterfaces, query]
)
const queryParse = useMemo(() => parseManualNetworkAddress(query), [query])
const showInlineError = query.trim() !== '' && !queryParse.ok
const handleSelectInterface = useCallback(
(iface: MobileNetworkInterface) => {
setCustomAddress(null)
setQuery('')
setOpen(false)
onSelectedAddressChange(iface.address)
},
[onSelectedAddressChange]
)
const handleSelectUseQuery = useCallback(
(address: string) => {
setCustomAddress(address)
setQuery('')
setOpen(false)
onSelectedAddressChange(address)
},
[onSelectedAddressChange]
)
return (
<div className="rounded-lg border border-border/60 p-4">
<div className="mb-3 flex items-center gap-2">
<Wifi className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.406a35121c',
'Network Interface'
)}
</span>
</div>
<p className="text-muted-foreground mb-3 text-xs">
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.d536b5e20d',
'Choose which network address to advertise in the QR code. Use your LAN address for same-network pairing, or an overlay network address (Tailscale, ZeroTier) for cross-network access.'
)}
</p>
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
role="combobox"
aria-expanded={open}
className="min-w-[220px] justify-between font-normal"
>
<span className="truncate">{triggerLabel}</span>
<ChevronDown className="ml-2 size-3.5 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder={translate(
'auto.components.settings.MobileNetworkInterfaceSection.new-combobox-placeholder',
'Search or type an address…'
)}
/>
<CommandList>
<CommandEmpty>
{showInlineError ? ERROR_MESSAGE : translate(
'auto.components.settings.MobileNetworkInterfaceSection.new-combobox-empty',
'No matching interfaces'
)}
</CommandEmpty>
{entries.map((entry, index) => {
if (entry.kind === 'interface') {
return (
<CommandItem
key={`iface-${entry.iface.name}-${entry.iface.address}`}
value={`${entry.iface.address} ${entry.iface.name}`}
onSelect={() => handleSelectInterface(entry.iface)}
>
{formatInterfaceLabel(entry.iface)}
</CommandItem>
)
}
const isFirstUseQuery = index > 0
return (
<div key={`use-${entry.address}`}>
{isFirstUseQuery ? <CommandSeparator /> : null}
<CommandItem
value={`__use__ ${entry.address}`}
onSelect={() => handleSelectUseQuery(entry.address)}
>
Use &quot;{entry.address}&quot;
</CommandItem>
</div>
)
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
'auto.components.settings.MobileNetworkInterfaceSection.a9db5d771d',
'Refresh network interfaces'
)}
className="text-muted-foreground"
>
<RefreshCw className={refreshingNetworkInterfaces ? 'animate-spin' : ''} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.a9db5d771d',
'Refresh network interfaces'
)}
</TooltipContent>
</Tooltip>
</div>
{showInlineError ? (
<p className="text-xs text-statusRed" role="status">
{ERROR_MESSAGE}
</p>
) : null}
<Button
onClick={onGenerateQr}
disabled={loading || !selectedAddress}
size="sm"
className="gap-1.5"
>
{loading ? (
<Loader2 className="size-3.5 animate-spin" />
) : hasQrCode ? (
<RefreshCw className="size-3.5" />
) : (
<QrCode className="size-3.5" />
)}
{hasQrCode
? translate(
'auto.components.settings.MobileNetworkInterfaceSection.1e64659126',
'Regenerate'
)
: translate(
'auto.components.settings.MobileNetworkInterfaceSection.c541f67790',
'Generate QR Code'
)}
</Button>
</div>
<Accordion type="single" collapsible className="mt-4 border-t border-border/60 pt-2">
<AccordionItem value="remote-pairing-guide">
<AccordionTrigger className="py-2 text-xs">
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.39fad211d9',
'Connect outside your Wi-Fi with a tailnet'
)}
</AccordionTrigger>
<AccordionContent className="space-y-3 text-xs text-muted-foreground">
<p>
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.9fc5d203ff',
'Orca Mobile connects directly to this computer. To use it away from the same local network, put your computer and phone on the same private overlay network, then generate the QR code with that network address selected.'
)}
</p>
<ol className="list-decimal space-y-1 pl-4">
<li>
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.51d29927eb',
'Install'
)}{' '}
<button
type="button"
onClick={() => void window.api.shell.openUrl(TAILSCALE_DOWNLOAD_URL)}
className="inline-flex items-center gap-1 font-medium text-foreground underline-offset-2 hover:underline"
>
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.1dc87a7fbc',
'Tailscale'
)}
<ExternalLink className="size-3" />
</button>{' '}
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.668016be7a',
'on your computer and phone.'
)}
</li>
<li>
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.1f7c26d36a',
'Sign in to the same tailnet on both devices.'
)}
</li>
<li>
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.87985ba6f5',
'In this Network Interface menu, choose the Tailscale address, usually a 100.x.y.z IP.'
)}
</li>
<li>
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.63d5e4ae1e',
'Regenerate the QR code and scan it from the Orca mobile app.'
)}
</li>
</ol>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)
}
```
Notes for the implementer:
- `Command shouldFilter={false}` because we already filter in `buildComboboxEntries`. Without this, cmdk's default fuzzy filter would re-filter the manually-injected `__use__` row out of view.
- The `value` prop on each `CommandItem` keeps cmdk happy but does not affect our filtering; we own filtering.
- `useMemo` is used for `selectedIface`, `entries`, and `queryParse` so re-renders triggered by parent prop changes don't churn the combobox state.
- If `isCustomLabel` is unused after a lint pass (no caller in the final file), delete the helper — the spec lists it but the implementation inlines the check.
- [ ] **Step 6: Run the new test file**
```bash
pnpm vitest run src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx
```
Expected: all six tests pass.
If any fail:
- `toHaveTextContent` mismatches → check the trigger label format.
- `getByRole('option', { name: /Use "…"/ })` not found → verify `Command shouldFilter={false}` is set.
- Inline error not appearing → verify `showInlineError` is computed against `queryParse.ok`.
- [ ] **Step 7: Run the full test suite for the settings folder**
```bash
pnpm vitest run src/renderer/src/components/settings/
```
Expected: all green. Confirm nothing else broke (e.g. `MobilePairingQrSection.test.tsx` if it exists).
- [ ] **Step 8: Lint, typecheck, build**
```bash
pnpm lint
pnpm typecheck
pnpm build
```
Expected: all green. If `pnpm lint` complains about an unused import (e.g. `isCustomLabel` or `CommandSeparator`), remove it and re-run.
- [ ] **Step 9: Commit**
```bash
git add src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx \
src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx
git commit -m "feat(mobile-pairing): combobox with manual address entry"
```
---
## Task 4: End-to-end verification & PR prep
**Files:** none modified; verification only.
- [ ] **Step 1: Run the four CI checks locally**
```bash
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
Expected: all green. This is exactly what `.github/workflows/` runs.
- [ ] **Step 2: Manual smoke check**
If Orca can be launched locally with the desktop build:
1. Open Settings → Mobile.
2. Click the Network Interface dropdown — confirm both interfaces list.
3. Type a tailnet address — confirm the `Use "…"` row appears.
4. Click `Use "…"` — confirm the trigger switches to `… (custom)`.
5. Click Refresh — confirm the custom selection survives.
6. Type an invalid string — confirm the inline error appears and no `Use …` row is shown.
7. Type an address that already matches an interface (e.g. `192.168.1.24`) — confirm the `Use` row is hidden and selecting the interface row does NOT show `(custom)`.
Skip any step that requires a running desktop build and note it in the PR description.
- [ ] **Step 3: Capture before/after screenshots**
Save screenshots to a temp directory (NOT to the repo — `CONTRIBUTING.md` and `AGENTS.md` forbid committing PR evidence images). Use gstack browse if running the production binary:
```bash
$HOME/.claude/skills/gstack/browse/dist/browse screenshot /tmp/orca-mobile-section-before.png --selector ...
```
Attach the screenshots to the PR conversation (never use `gh-attach`).
- [ ] **Step 4: Push the branch and draft the PR**
```bash
git push origin HEAD
gh pr create \
--title "feat(mobile-pairing): combobox with manual network address entry" \
--body-file /tmp/pr-body.md
```
The PR body should follow `pull_request_template.md`:
- **Summary:** explain that the desktop Network Interface dropdown now accepts a manually-typed IPv4 or Tailscale MagicDNS hostname.
- **Screenshots:** link to the conversation-attached screenshots.
- **Testing:** check all four `pnpm` boxes; note that `pnpm test` includes the three new test files (`manual-address.test.ts`, `mobile-network-interface-selection.test.ts`, `MobileNetworkInterfaceSection.test.tsx`).
- **AI Review Report:** confirm the review checked cross-platform (no platform-specific code; uses shadcn primitives + cmdk patterns already in the codebase), SSH/remote/local compatibility (no change to main-process IPC), agent/integration compatibility (no change), performance (UI re-render cost is bounded by `useMemo` over the interface list), UI quality (follows STYLEGUIDE.md; uses `text-statusRed` for the error), security (no new IPC; the address flows through the existing `selectedAddress` prop which `MobilePairingQrSection` already validated).
- **Security Audit:** no new IPC, no new auth surface, no new dependency, no new env var. The parser rejects malformed input early so the QR endpoint never sees invalid data.
- **Notes:** none — single platform-agnostic renderer change.
- **X handle:** include the contributor's X handle per CONTRIBUTING.md so maintainers can shout out.
- [ ] **Step 5: Request review**
```bash
gh pr edit --add-reviewer <maintainer-handle>
```
Or comment `@<maintainer>` in the PR if reviewer auto-assignment isn't available.
---
## Definition of done
- All four `pnpm` checks pass locally.
- All new and existing tests pass.
- The component renders correctly in a smoke test (or smoke-test steps are documented as skipped in the PR).
- The PR is open with a body matching `pull_request_template.md`.
- The contributor's X handle is in the PR description.
@@ -0,0 +1,131 @@
# SSH Repo Host Reconciliation Design
Date: 2026-07-10
## Problem
The proven flow is **Settings -> SSH -> remove host -> re-add the same host**. Main matches the
removal tombstone and `Store.reassignSshTargetId` moves the persisted repo from the old target ID to
the new target ID without changing `Repo.id`. A renderer catalog merge can then retain the cached
old-target row alongside the fetched new-target row because repo rows are keyed by execution host
and repo ID.
The stale row can route a terminal or destructive action to a removed SSH target. PR #7997 made
worktree deletion host-scoped and fail closed; this follow-up removes the superseded renderer row
without weakening that boundary.
## Why UUID Inference Is Unsafe
The same `Repo.id` can legitimately exist on multiple hosts. For example, a local checkout and a
checkout on an SSH server can share the repository UUID. Removing the SSH host without re-adding it
must keep the SSH ghost visible so the user can forget it. A local or runtime row with the same UUID
does not prove that the SSH row moved.
Main already has exact migration evidence. `readoptOrphanedWorkspacesForTarget` selects a removed
target tombstone, and `Store.reassignSshTargetId` knows which repo IDs it moved from that old target
to the new target. The renderer must consume this evidence instead of inferring migration from live
siblings or SSH target metadata.
## Ordering Gap
Main sends `repos:changed` before `ssh:addTarget` or `ssh:importConfig` returns. Either order is
possible in renderer state:
1. The catalog transaction can merge old and new rows before the add response supplies migration
evidence.
2. The add response can supply evidence while renderer state still contains only the old row.
Evidence must therefore remain pending until the corresponding new direct-SSH row arrives. At that
point the renderer removes only the mapped `(repoId, oldTargetId)` row and its old host setup, and
moves cached worktree ownership onto the new host.
## Non-Goals
- Do not re-key repo or worktree UUIDs or introduce compound serialized IDs.
- Do not infer host migration from labels, paths, target-list absence, or same-UUID siblings.
- Do not remove PR #7997's execution-host context from destructive operations.
- Do not change git-provider behavior or add provider-specific assumptions.
## Design
1. `Store.reassignSshTargetId` returns the exact repo IDs it moved instead of only a count.
2. Main records a list of `{ oldTargetId, newTargetId, repoIds }` for each add/import operation.
3. The SSH IPC add and import results return the changed targets plus this migration evidence. Main
still sends `repos:changed` when at least one repo moved.
4. Both desktop SSH management surfaces record the evidence in the repo slice immediately after the
invoke resolves. The web preload returns an empty evidence list because target management is
desktop-only.
5. The repo slice merges duplicate evidence and keeps unresolved repo IDs pending. Every local,
targeted-runtime, and all-host catalog transaction reconciles against the latest pending set.
6. Reconciliation requires the exact new direct-SSH owner to exist before pruning the exact old
direct-SSH owner. Local rows, runtime-owned rows, unrelated SSH hosts, and same-UUID siblings are
not considered evidence.
7. When a repo row is pruned, its matching `ProjectHostSetup` is removed. The desktop catalog owns
local and direct-SSH setups; `runtime:<environmentId>` setups remain authoritative on the remote
Orca server.
8. Cached visible and detected worktree rows for the migrated repo are moved from the exact old SSH
host to the new host. If a new-host worktree row already arrived, it wins and the stale duplicate
is discarded.
9. A host-scoped worktree response is ignored if its captured repo/host owner no longer exists.
Repo catalog transactions also reapply pending worktree migration, covering responses that land
after evidence arrives but before the new repo row.
```text
main: remove target -> tombstone
main: re-add matching identity -> reassign old target ID to new target ID
main: return exact (old target, new target, moved repo IDs)
renderer: retain evidence until catalog includes the exact new SSH owner
renderer: remove only the exact old SSH row/setup and migrate cached worktree ownership
```
## Safety Constraints
- A local or runtime sibling with the same UUID never supersedes an SSH ghost.
- A mapping is not consumed until the exact new direct-SSH row exists.
- Runtime-owned SSH rows and `runtime:<environmentId>` setup rows remain untouched.
- Catalog transactions reconcile inside the Zustand updater so overlapping responses use the latest
repos and pending evidence.
- Missing, offline, or unhydrated target metadata is not deletion or migration evidence.
## Test Plan
- Persistence returns exact migrated repo IDs and leaves other hosts untouched.
- Tombstone re-adoption returns exact old/new/repo mappings for manual add and multi-host import.
- SSH IPC returns the evidence, sends `repos:changed`, and clears operation-local evidence.
- Pure renderer tests prove exact pruning, pending evidence, unrelated-host preservation, runtime
ownership preservation, and evidence deduplication.
- Store tests cover both event orders: catalog first and evidence first.
- Remove-without-re-add keeps a forgettable SSH row and setup when a local repo shares its UUID.
- Old direct-SSH setups are removed only for proven migrations; runtime setups remain.
- Old/new cached worktree duplicates collapse to the authoritative new-host row, and an old-only
worktree row migrates to the exact new host.
- A deferred old-host worktree response cannot restore old visible or detected ownership after the
new repo catalog consumes the migration evidence.
- Targeted runtime and all-host catalog transactions reconcile against the latest state.
Run focused tests, typecheck, lint, `pnpm check:max-lines-ratchet`, and the production build required
by the repository.
## Electron Validation
Use an isolated profile and a throwaway Linux SSH target:
1. Add a repo through the throwaway SSH target.
2. Remove the SSH target and re-add the same identity without restarting the renderer.
3. Confirm exactly one SSH project row remains and its worktree opens.
4. Confirm Explorer lists the remote files without an ambiguous-host routing error.
5. Repeat the cycle and confirm the stale row does not return.
6. Keep an adjacent local project visible and confirm it remains unchanged.
There is no new control or copy. The behavioral evidence is the stable row, successful workspace
routing, and deterministic store assertions.
## Performance And Scope
Renderer reconciliation indexes direct SSH repo owners once, then processes each migrated repo ID
once. Setup cleanup is linear in current repos and setups. The change adds no polling, listeners,
subprocesses, or persistent renderer state. IPC payload growth is bounded by the repo IDs actually
migrated during the user-triggered add/import operation.
Windows and POSIX paths do not participate in reconciliation. SSH identity matching remains the
existing strict alias or host/user/port logic, and git-provider identity is not used.
+108
View File
@@ -0,0 +1,108 @@
# Project Ordering Mode
## Problem
The sidebar can group workspaces by project, but project header order is currently coupled to workspace sorting instead of having its own user choice.
- `src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx:59` defines one `Sort by` control for workspaces; it has no separate project-order setting.
- `src/renderer/src/store/slices/ui.ts:723` stores only `sortBy`, and `src/shared/types.ts:2543` persists the same workspace sort field.
- `src/renderer/src/components/sidebar/worktree-list-groups.ts:36` derives `ProjectGroupOrdering` from `sortBy`, so project headers follow the highest-ranked visible workspace in Recent/Smart and fall back to manual order otherwise.
- `src/renderer/src/components/sidebar/WorktreeList.tsx:3765` computes the ordered workspace ids, and `src/renderer/src/components/sidebar/WorktreeList.tsx:4060` feeds the derived project ordering into `buildRows(...)`.
- `src/renderer/src/components/sidebar/WorktreeList.tsx:826` only enables project-header drag when project grouping is manual and there are no Project Groups.
- `src/renderer/src/components/sidebar/project-header-drag.ts:1` already implements pointer-based project header dragging for virtualized rows, and `src/renderer/src/store/slices/repos.ts:699`, `src/main/ipc/repos.ts:1029`, and `src/main/persistence.ts:2497` already persist whole-repo manual reorders.
Result: users cannot choose Manual vs Recent ordering specifically for projects, the default is not Manual, and changing workspace sort can unexpectedly reorder projects.
## Goal
Add a project-only ordering preference with two modes:
- `Manual`, the default. Project headers render in the persisted manual project order, and users can drag project headers to reorder them.
- `Recent`. Project headers render by the most recent visible workspace activity in each project.
This must affect only project header order in `groupBy: 'repo'`. Worktree/workspace rows inside each project must continue to use the existing workspace `sortBy`, filtering, pinning, lineage, status, and manual-order behavior.
## Non-goals
- Do not change `sortBy` semantics or the workspace `Sort by` control.
- Do not change worktree `manualOrder`, `sortOrder`, `lastActivityAt`, or `buildWorktreeComparator(...)`.
- Do not reorder worktrees inside a project when the project ordering mode changes.
- Do not change Project Group header order; groups continue to use `ProjectGroup.tabOrder`.
- Do not add GitHub-specific behavior. Repo/project ordering must remain provider-neutral and work for GitLab, folder projects, local repos, SSH repos, and runtime environments.
- Do not add telemetry in this pass unless a later product requirement asks for it.
## Design
1. Add a persisted project-order preference.
- Introduce `ProjectOrderBy = 'manual' | 'recent'` in `src/shared/types.ts` or a concrete sidebar ordering module.
- Add `projectOrderBy: ProjectOrderBy` to `PersistedUIState`, defaulting to `'manual'` in `getDefaultUIState()` (`src/shared/constants.ts:387`).
- Add `projectOrderBy` and `setProjectOrderBy` to the UI slice next to `sortBy` (`src/renderer/src/store/slices/ui.ts:723`). Like `setSortBy`, this setter is a bare `set({...})` — it does not persist on its own.
- Persist through the debounced UI writer, not the setter. `sortBy`/`groupBy` reach disk only via the explicit field list in `App.tsx`'s `window.api.ui.set({...})` effect (`src/renderer/src/App.tsx:1019`) plus its dependency array (`src/renderer/src/App.tsx:1042`). Add `projectOrderBy` to both, or the value lives only in memory and silently resets to `'manual'` every restart.
- Normalize persisted values in `src/main/persistence.ts` the same way `sortBy` is normalized at `src/main/persistence.ts:323`, and wire the normalizer into both `getUI()` (`src/main/persistence.ts:3116`) and `updateUI()` (`src/main/persistence.ts:3147`); invalid or missing values resolve to `'manual'`.
- Hydration in `src/renderer/src/store/slices/ui.ts:1862` should read the normalized value without migrating existing `sortBy`.
2. Expose the choice in the sidebar options menu.
- In `SidebarWorkspaceOptionsMenu`, keep the existing `Sort by` submenu as workspace sorting.
- Add a second radio submenu labeled `Project order` with `Manual` and `Recent`.
- Show it only when `groupBy === 'repo'`, because project ordering has no visible effect in `none`, `workspace-status`, or `pr-status`.
- Use existing `DropdownMenuRadioGroup` and sidebar/menu styling from `docs/STYLEGUIDE.md`; no new tokens or custom colors.
- Copy should make the scope clear: Manual description `Drag projects to arrange them`; Recent description `Most recent workspace activity`.
3. Decouple project header ordering from workspace sorting.
- Replace `getProjectGroupOrdering(groupBy, sortBy)` with a project-order resolver that depends on `groupBy` and `projectOrderBy`, not workspace `sortBy`.
- Update `buildRows(...)` so `groupBy !== 'repo'` ignores project order, `projectOrderBy === 'manual'` uses persisted manual ranks, and `projectOrderBy === 'recent'` sorts project header entries by a per-repo recent timestamp.
- Recent must be timestamp-based, not first-encounter. Today's `'visible-worktree-order'` path (`src/renderer/src/components/sidebar/worktree-list-groups.ts:548`) works only because the caller pre-sorts the worktree array by recency when `sortBy` is `recent`/`smart`. Decoupling from `sortBy` removes that guarantee — the incoming array may be name- or manual-sorted — so the Recent resolver must explicitly compute `max(lastActivityAt)` per repo rather than relying on encounter order.
- Recent project rank should be the maximum `lastActivityAt` among that repo's visible, unpinned worktrees passed into `buildRows(...)`. Empty placeholder projects and imported-worktree-card-only projects fall back to `Repo.addedAt`, then manual rank, then label.
- Do not reorder `group.items`; only reorder the project header entries before `appendOrderedGroups(...)`. This preserves the existing `orderMainWorktreeFirst(...)` and workspace row ordering at `src/renderer/src/components/sidebar/worktree-list-groups.ts:632`.
- With Project Groups, keep group headers ordered by `tabOrder`; apply Manual/Recent only to project entries within each group or the ungrouped bucket.
4. Make Manual drag/drop project-scoped (no-Project-Groups case only in v1).
- Enable project header dragging when `groupBy === 'repo' && projectOrderBy === 'manual'`, regardless of workspace `sortBy`. Today `canReorderRepoHeaders` is additionally gated on `!hasProjectGroups` (`src/renderer/src/components/sidebar/WorktreeList.tsx:827`); the only change here is dropping the `sortBy`-derived `projectGroupOrdering` input in favor of `projectOrderBy === 'manual'`. The `!hasProjectGroups` gate stays.
- Keep using pointer events from `project-header-drag.ts` unchanged; the virtualized-row reasoning at `src/renderer/src/components/sidebar/project-header-drag.ts:3` still applies. For the no-Project-Groups case it already does exactly what we need: a flat whole-list permutation committed through `reorderRepos(...)`, preserving current behavior and IPC rejection semantics.
- Continue to suppress click-to-collapse only after a promoted drag, matching `src/renderer/src/components/sidebar/project-header-drag.ts:142`.
- Defer grouped (within-Project-Group) drag to a follow-up. `useRepoHeaderDrag` is built for a single flat `orderedRepoIds` permutation: `endDrag` (`src/renderer/src/components/sidebar/project-header-drag.ts:130`) computes `fromIndex`/`insertAt` over one array and calls `onCommit``reorderRepos`, and `onHandlePointerDown` (`src/renderer/src/components/sidebar/project-header-drag.ts:300`) snapshots every on-screen `[data-repo-header-id]` with no notion of group boundaries. Supporting in-group reorder needs new hook logic — bucket-aware drop targets, rejecting drops outside the source sibling bucket, computing a midpoint between neighbor `projectGroupOrder` values, and a second commit mode that calls `moveProjectToGroup(projectId, sameGroupId, order)` (`src/renderer/src/store/slices/repos.ts:138`) instead of `reorderRepos`. This is its own change and is out of scope for v1.
- In v1, Project-Groups users keep the project actions menu as the move surface (it already calls `moveProjectToGroup`). Manual project order still applies to their headers via the row-builder sort (step 3); only drag-to-reorder is unavailable inside groups.
- Do not turn a project reorder drag into a cross-group move in any version. The existing project actions menu remains the cross-group move surface.
5. Keep persistence and runtime parity intact.
- Local no-group manual reorder continues through `repos:reorder` (`src/main/ipc/repos.ts:1029`) and `Store.reorderRepos(...)` (`src/main/persistence.ts:2497`).
- Remote no-group manual reorder continues through `repo.reorder` (`src/renderer/src/store/slices/repos.ts:723`).
- Grouped manual reorder via drag is deferred (see step 4); the follow-up would route through `projectGroup.moveProject` / `moveProjectToGroup(...)`, which already handles local and runtime-environment calls. In v1, grouped projects move only through the existing actions menu.
- The project-order preference is renderer UI state, not repo metadata. It persists through the `App.tsx` debounced `window.api.ui.set({...})` writer (step 1), not through any repo record.
6. Tests.
- Add row-builder tests proving `projectOrderBy: 'manual'` orders project headers by `repoOrder` without changing workspace row order.
- Add row-builder tests proving `projectOrderBy: 'recent'` orders project headers by max visible `worktree.lastActivityAt` while preserving each project's child row order.
- Add Project Group tests for Manual and Recent within groups, plus unchanged `ProjectGroup.tabOrder`.
- Rewrite, do not extend, the existing Recent/`getProjectGroupOrdering` tests. The current cases at `src/renderer/src/components/sidebar/worktree-list-groups.test.ts:639` ("orders repo headers by first encounter…"), `:663` ("…highest-ranked visible child"), and the `getProjectGroupOrdering` block at `:752` all assert the old first-encounter coupling and are semantically incompatible with timestamp-based Recent. Replace them with tests for the new resolver and the `max(lastActivityAt)` ordering.
- Add UI slice/persistence normalization tests for default Manual, invalid value fallback, and hydration. Include a persistence-writer test (or note) that `projectOrderBy` is part of the `App.tsx` `ui.set` payload so it actually round-trips across restart.
- Grouped manual drag persistence tests are deferred with the grouped-drag feature (step 4).
## Edge cases
- Existing users with no `projectOrderBy` get Manual, even if their workspace `sortBy` is Recent.
- Changing workspace `sortBy` must not change project header order unless `projectOrderBy === 'recent'` and the visible worktree set/activity data changes.
- Changing `projectOrderBy` must not mutate any worktree `manualOrder` or repo order by itself.
- Manual drag while a project is added or removed can race. Whole-repo reorder (the only v1 drag path) keeps the existing permutation rejection and refetch behavior. (Grouped midpoint writes and their refetch-on-failure handling come with the deferred grouped-drag follow-up.)
- In Recent mode, projects with no visible worktrees should still render when they are placeholders or imported-worktree-card candidates; they sort after projects with activity.
- Pinned worktrees remain in the Pinned section. They should not make their project jump in Recent ordering unless an unpinned visible workspace in that project is also recent.
- Filters and hidden sleeping/default-branch workspaces affect the visible worktree set. Recent project order should reflect the rows the user can currently see.
- Project Group collapse state must not change when the order mode changes.
- Dragging a project inside a collapsed Project Group is impossible because its repo headers are not mounted; no special handling is needed.
- Dragging across Project Group boundaries should not silently move the project; cross-group moves stay on the actions menu. (In v1 there is no in-group drag at all — see step 4 — so this only constrains the deferred grouped-drag follow-up.)
- SSH/runtime projects must use the same store actions as local projects. No local filesystem path assumptions are needed.
- Folder projects have synthetic worktrees and should participate through the same `lastActivityAt` and manual repo order paths.
- Multi-window or external mutations are last-writer-wins through existing persistence. A rejected whole-repo permutation refetches repos (existing behavior); a failed grouped move would do the same once grouped drag lands.
- Behavior change on upgrade: existing users on `sortBy: recent`/`smart` currently see project headers bubble to follow workspace activity. After this change they default to Manual project order, so headers stop bubbling until they pick Recent in the new submenu. This is intended (matches the "no `projectOrderBy` → Manual" edge case) but is a visible change worth calling out in release notes.
## Rollout
1. Add `ProjectOrderBy` types/defaults/normalization, the UI slice setter, the `App.tsx` debounced-writer field, and persistence `getUI()`/`updateUI()` wiring.
2. Add the `Project order` submenu in `SidebarWorkspaceOptionsMenu`.
3. Update `WorktreeList` to read `projectOrderBy`, pass it to row construction, and enable project drag only in Manual project order (no-Project-Groups case, keeping the `!hasProjectGroups` gate).
4. Update `worktree-list-groups.ts` to order project headers by Manual or Recent independently from workspace `sortBy`, with Recent using `max(lastActivityAt)` per repo.
5. Add focused row-builder and UI persistence/round-trip tests; rewrite the incompatible first-encounter/`getProjectGroupOrdering` tests.
6. Run targeted Vitest for sidebar row ordering and repo slice tests, then `pnpm typecheck` and `pnpm lint`.
7. Validate in Electron: default startup shows Manual project order, the choice survives restart, project drag/drop reorders project headers only (ungrouped), Recent project order follows workspace activity, and worktree rows inside each project do not change when toggling project order.
Deferred follow-up (separate change): extend `useRepoHeaderDrag` for grouped sibling buckets — bucket-aware drop targets and a second commit mode using `moveProjectToGroup(...)` midpoint ordering inside Project Groups — plus its grouped-drag persistence tests.
@@ -0,0 +1,783 @@
# Reliability Gates Implementation Plan
Date: 2026-07-02
Source context: [`docs/reference/reliability-pain-points-2026-06-30.md`](./reliability-pain-points-2026-06-30.md).
## Current Status
Last updated: 2026-07-03.
The first repo-side reliability batch is useful, but it should be treated as a first-layer regression net, not a finished terminal reliability program. Before final merge claims, rebase or retarget the stack onto fresh `origin/main` and verify it still includes the protections that landed there after the branch was cut.
Stack topology facts the rest of this plan depends on (verified 2026-07-02):
- `brennanb2025/fix-terminal-reliability` is simultaneously the integration worktree and the head branch of #7001, and it is the base branch of all five child PRs (#7004-#7008). Committing broad product changes to it would turn #7001 into the single large terminal PR this plan forbids and would silently change every child PR's diff.
- The committed branch contains only the manifest, checker, checker tests, and these docs (a 5-gate manifest as committed). Everything else this plan describes — the expanded manifest, product hardening, and new or updated tests across roughly 39 modified files plus 3 untracked files — exists only as uncommitted working-tree changes in this worktree. No evidence run below is reproducible from any pushed ref until the split branches are committed and pushed.
- The branch's merge-base with `origin/main` is roughly 97 commits stale. Fresh main renamed `terminal-webgl-paste-recovery.{ts,test.ts}` to `terminal-webgl-atlas-recovery.{ts,test.ts}` in #6949, so the current `xterm-addon.boundary-containment` manifest entry and its documented command break on rebase; see Fresh-Eyes Corrections.
- The manifest is the single source of truth for gate counts and per-gate status. Where this document records counts they are dated snapshots; `pnpm run check:reliability-gates` prints the current gate total.
Known Orca PR stack:
- [#7001](https://github.com/stablyai/orca/pull/7001), `Add reliability gate manifest`: merged on 2026-07-03. The manifest, checker, and plan docs are on main; [#7295](https://github.com/stablyai/orca/pull/7295) hardens the checker policy and registers the merged #7133/#7148/#7173/#7192 regression tests as partial gates with fresh main-side evidence.
- [#7005](https://github.com/stablyai/orca/pull/7005), `Add terminal snapshot freshness contract gate`: depends on #7001 and protects the stale-liveness/newborn-PTY class behind `terminal-session.snapshot-freshness`.
- [#7008](https://github.com/stablyai/orca/pull/7008), `Expand provider session replay ownership gate`: depends on #7001 and protects the agent/provider ownership class behind `agent-session.provider-ownership`.
- [#7004](https://github.com/stablyai/orca/pull/7004), `Contain xterm addon failures to terminal panes`: depends on #7001. The current branch has a first executable xterm containment slice for addon load, link-provider, search, and WebGL failure containment; it still needs live typed input/output survival before promotion.
- [#7006](https://github.com/stablyai/orca/pull/7006), `Prove visible terminal size convergence`: depends on #7001. The current branch now has a first deterministic renderer/main contract slice for post-spawn reconcile, split-right 0x0 recovery, applied `pty:getSize`, and resume-time size reassertion; it still needs live shell, SSH/remote, and Windows ConPTY geometry proof before promotion.
- [#7007](https://github.com/stablyai/orca/pull/7007), `Add persisted session upgrade fixture gate`: depends on #7001. The current manifest entry is still commandless in this branch, so treat it as a registered startup-upgrade gap until immutable fixture commands are wired.
Merged related product hardening:
- [#7054](https://github.com/stablyai/orca/pull/7054), `Prevent hidden terminal TUI overlap`: merged on 2026-07-01 with `verify`, Wayland terminal input, golden Linux E2E, golden mac E2E, and community tracking checks passing. This fixes the hidden regular-terminal TUI overlap class by scheduling bounded post-parse WebGL atlas recovery for risky hidden TUI redraws while keeping hidden bytes on the background write path.
- [#7133](https://github.com/stablyai/orca/pull/7133), `Fix stale terminal frames on worktree return`: merged on 2026-07-02 as a high-priority regression fix, root-caused on a live repro. Hidden-output restore skipped the destructive clear for alternate-screen snapshots, `?1049h` is a no-op on a pane already on the alt screen, and serialized frames skip blank cells, so the pre-hide frame bled through the final frame until a select or resize. The fix writes an alt-only `\x1b[?1049h\x1b[2J\x1b[H` preamble before alt-screen snapshot data (normal-buffer scrollback untouched, still no `3J`) and hardens the WebGL reveal path: dispose-on-bail with a single-addon invariant, refresh-always atlas reset, and a settled-frame pane-scoped repaint on tab reveal, worktree resume, and window wake. It supersedes the reverted #7058 approach and closes the alt-screen hole that #7054's hidden-output skip machinery made load-bearing. Its deterministic tests — the alt-only-clear-before-snapshot assertions in `pty-connection.test.ts`, plus `pane-webgl-renderer.test.ts`, `pane-reveal-repaint.test.ts`, and `terminal-visibility-resume.test.ts` — are the seed assertions for `terminal-output.scrollback-restore` and the WebGL side of `xterm-addon.boundary-containment` once the stack rebases.
- [#7148](https://github.com/stablyai/orca/pull/7148), `Match main terminal mirror character widths to the renderer`: merged on 2026-07-02, hours after #7133. Root cause of the residual post-#7133 bottom-row tears: the renderer xterm measures with Unicode 11 + ZWJ joining while the daemon headless mirror used default-width tables, so every positioned write after an emoji lands shifted and the tears are baked into the very mirror that restores replay — #7133's clear made restores faithful to the mirror and exposed it. The fix moves the unicode provider to `src/shared/terminal-unicode-provider.ts` and activates it in the headless emulator. Its red-green width test, `src/main/daemon/headless-emulator-unicode-width.test.ts` on main, is the seed assertion for the new `terminal-mirror.parser-parity` gate, and the shared provider plus `pane-terminal-options.ts` are the shared-construction seams that gate must assert on. #7148 also touches `pane-lifecycle.ts`, which both this worktree and #7004's branch modify — another confirmed rebase-conflict site.
- [#7173](https://github.com/stablyai/orca/pull/7173), `Fix Codex pane output tearing after app occlusion`: merged on 2026-07-03. A delayed background-origin Codex chunk carrying a stateful query could take the hidden live-write path after a newer snapshot restore had already rebuilt the renderer, overwriting newer state; an adversarial review pass added seq-restart guards so revived sessions (hibernation wake, kill plus cold-restore under the same PTY id) cannot silently freeze hidden output against a stale seq high-water mark. Its three red-proven tests in `pty-connection.test.ts` on main — reference-parse buffer equality over the ordered raw stream plus both revival guards — are exactly the exactness-oracle shape this plan prescribes and seed the hidden/live interleaving slice of `terminal-output.scrollback-restore`.
- [#7192](https://github.com/stablyai/orca/pull/7192), `Keep runtime mirror sized with desktop terminal resizes`: merged on 2026-07-03. Desktop `pty:resize` updated the PTY and renderer but never the runtime mirror, so the mirror parsed all subsequent bytes at its birth width, soft-wrapped TUI repaints into a corrupted ladder, and baked that corruption into every restore snapshot; the fix fans accepted desktop resizes to the mirror and serializes mirror reflow with queued output writes on the per-PTY write chain. This exposed a plan gap now fixed: the runtime mirror is a geometry authority in its own right, and renderer-vs-mirror convergence checks cannot see corruption upstream of the mirror. Its red-proven tests in `orca-runtime.test.ts` and `pty.test.ts` on main seed the mirror-geometry slice of `terminal-geometry.visible-convergence`.
Factory and skill status:
- [orca-internal #491](https://github.com/stablyai/orca-internal/pull/491), `Require reliability gates in factory reviews`: merged on 2026-07-01. This updates the factory review flow so terminal/session/provider/startup/release changes must name the relevant reliability class, invariant, gate or accepted gap, and validation evidence.
- [orca-internal #493](https://github.com/stablyai/orca-internal/pull/493), `Add Playwright reliability test skill`: as last reviewed, this still needed rebase/regeneration before merge because the reliability plan depends on Playwright tests being evidence-based, low-flake, and tied to named invariants.
Remaining work:
- Rebase or retarget the reliability stack on fresh `origin/main` before making final merge claims. Current main already contains terminal fixes that the stack must absorb, including hidden TUI overlap, input-lag, scrollback replay, Windows ConPTY keyboard reset, resume width flicker, remote mirror polling, and the #7133 stale alt-screen frame fixes. As part of the rebase, mechanically verify every declared `testFiles` entry in the manifest exists on fresh `origin/main`; the #6949 WebGL rename is a confirmed break. #7133 (merged 2026-07-02) touches `pty-connection.{ts,test.ts}` and `use-terminal-pane-global-effects.test.ts`, which this worktree also modifies, and `pane-webgl-renderer.{ts,test.ts}`, which #7004's branch also edits — expect real conflicts there. #7148 (merged 2026-07-02) additionally touches `pane-lifecycle.ts` and `headless-emulator.ts`, so the worktree's and #7004's pane-lifecycle edits must absorb its shared unicode-provider wiring. #7173 (merged 2026-07-03) rewrites the hidden live-write path in `pty-connection.{ts,test.ts}` and #7192 (merged 2026-07-03) touches `pty.test.ts` — both carry heavy worktree modifications, so the conflict surface keeps widening the longer the rebase waits. This worktree's replay/SSH/backpressure changes and #7004's containment changes must be rebased over #7133's alt-clear preamble, #7148's width fix, #7173's seq filtering, #7192's mirror resize fan-out, and the reveal-repaint hardening without weakening any side.
- Materialize the split: commit each uncommitted working-tree slice to its owning branch per the file assignment in the PR Split Plan, reset diverged child branches from the worktree versions of their files, and push. Only manifest, checker, and doc changes may be committed to `brennanb2025/fix-terminal-reliability` itself.
- Wire the aggregated non-blocking `reliability-gates` CI job (see Evidence Provenance And CI Wiring) so runtime and flake history accrue with machine provenance instead of hand-entered evidence.
- Split the current integration branch into focused PRs before final review and merge. Do not land this as one large terminal reliability PR; each PR should have its own invariant, evidence command, residual gaps, dependency note, and follow-up relationship to the rest of the stack.
- #7001 merged on 2026-07-03. Merge the gate-specific PRs only after resetting each from the worktree slice per the file assignment.
- Before merging each child PR, recheck it against the latest #7001/main because the shared manifest file can create normal stack drift or conflicts even when the PR currently reports clean.
- After merge, run the registered gate commands in soak and record runtime/flake evidence before promoting any gate to blocking.
- Update the factory skills again after the repo-side gate commands and CI jobs are stable, so agents can invoke exact commands instead of only requiring reliability evidence.
- Keep #7054's accepted gap visible: deterministic Claude-like TUI streams validated the renderer path, but live Claude Code was not part of that PR's validation. #7133 has since closed the alt-screen snapshot-restore hole in the same hidden-output machinery, and its buffer-merge root cause is exactly the class the `terminal-output.scrollback-restore` gate must make hard to reintroduce; the live-Claude validation gap remains.
## PR Split Plan
The current `brennanb2025/fix-terminal-reliability` branch is an integration branch, not the desired final review shape. It should be split into smaller PRs so reviewers can verify one reliability class at a time, bisect failures, and avoid merging a broad terminal lifecycle change without understanding which protection each file belongs to.
Branch and manifest ownership rules:
1. `brennanb2025/fix-terminal-reliability` is #7001's head branch. Only manifest, checker, checker-test, and plan-doc changes may be committed to it. Split items 2-6 go to their own branches, stacked on this branch and retargeted to `main` after #7001 merges.
2. #7001 may only declare gates whose `testFiles` all exist on `origin/main`. The checker's declared-file rule makes this self-enforcing: a manifest entry whose test files land in a later PR cannot pass CI inside #7001.
3. Every later PR owns its own manifest entries. It adds or upgrades the gates it implements — entry, `evidenceRuns`, and `assertionRefs` — in the same PR as the tests and hardening they reference. The manifest file is shared, but each entry has exactly one owning PR, and manifest conflicts are resolved by entry ownership, never by hand-merging divergent copies of an entry.
4. Commandless registered-gap entries (`protection: "none"`, no test files) may ride in #7001 because they reference nothing that can drift.
5. The integration worktree is the single authoritative copy of every overlapping file. Child branches that have diverged (for example, `resume-sleeping-agent-session.ts` on #7008's branch is ~156 diff lines behind this worktree) must be reset from the worktree slice, not reconciled by hand.
Recommended split:
1. Reliability gate manifest and plan docs.
This PR owns `config/reliability-gates.jsonc`, the manifest checker hardening, checker tests, and this plan document. It should merge first because later PRs reference gate ids, evidence metadata, and promotion rules from the manifest. It should not include terminal product behavior changes.
2. Provider ownership and session replay safety.
This PR owns the agent/provider identity invariant: activation, restore, sleep, hibernate, dedupe, clearing, and reconnect paths must not replay a provider session that is already owned, queued, pending, retained, or live in the workspace. It should include the provider-session ownership tests, repeat-activation lower-layer proofs, automatic resume claim coverage, same-session/wrong-session hook coverage, and any narrowly required product hardening.
3. Startup hydration, provider fanout, and targeted liveness.
This PR owns startup-adjacent PTY registry hydration, targeted `pty.hasPty(id)` liveness, no-hot `pty:listSessions()` paths, Resource Manager polling scoping, SSH/provider unknown-liveness semantics, degraded-daemon fail-closed behavior, and related tests. It should depend on the manifest PR and should explicitly say that live SSH, WSL, remote-runtime, and production startup reconcile remain follow-up gates unless implemented in the same PR.
4. Terminal lifecycle observability.
This PR owns compact anomaly breadcrumbs and bounded lifecycle traces for terminal lifecycle diagnosis. It should make failures easier to debug without claiming to prevent them. It should prove that diagnostics are compact, deduped or bounded, privacy-conscious, and included in the right local crash/diagnostic surfaces. Diagnostics-bundle export and live forbidden-transition artifacts should remain visible follow-ups until proven.
5. PTY output, IPC, and performance hardening.
This PR owns daemon stream batching, main-to-renderer pending output caps, ACK/backpressure behavior, replay queue bounds, runtime/mobile stream byte budgets, and related perf-sensitive contracts. It needs the hardest review because small behavior changes here can cause input latency, hidden-output, memory, or ordering regressions. It should include explicit runtime budgets and should not promote any perf gate to blocking without soak evidence.
6. Live local PTY and E2E reliability slice.
This PR owns the first live Electron/local PTY liveness gate. It should stay small: spawn a real terminal, prove one active PTY, type through focused xterm, verify process-visible output, hide/restore the workspace without duplicating or losing the PTY, resize and verify applied size, then exit and verify cleanup. It should remain `experimental` until CI/runtime history proves it is not flaky. Tab switch, app restart, SSH, WSL, Windows ConPTY, and scrollback restore should be explicit follow-ups unless included with equally strong oracles.
Mapping to the open stack and file assignment:
The six split items and the five open child PRs overlap; this mapping is authoritative. Every working-tree file must land in exactly one PR below. Four files contain changes from multiple reliability classes and must be split by hunk, with the earlier PR in the merge order owning its hunks first: `config/reliability-gates.jsonc` (per-entry ownership), `src/main/ipc/pty.ts` and its test, `src/renderer/src/components/terminal-pane/pty-connection.ts` and its test, and `src/renderer/src/store/slices/terminals.ts`.
1. #7001 (split item 1, manifest and plan docs): `config/scripts/check-reliability-gates.mjs`, `config/scripts/check-reliability-gates.test.mjs`, `docs/reference/reliability-gates-implementation-plan.md`, and the manifest restricted to gates whose test files exist on `origin/main` plus commandless gap entries.
2. #7008 (split item 2, provider ownership and replay safety): `src/renderer/src/lib/resume-sleeping-agent-session.ts` and test, plus its ownership slice of `terminals.ts`. Reset the branch from the worktree slice; keep the PR number for review continuity.
3. #7005 (snapshot freshness and targeted renderer liveness): `terminal-dead-session-reconcile.ts` and test, `use-terminal-pane-lifecycle.ts` and test, `use-terminal-pane-global-effects.test.ts`, and the `pty.hasPty` preload/API plumbing (`src/preload/api-types.ts`, `src/preload/index.ts`, `src/renderer/src/web/web-preload-api.ts`, the `hasPty` slice of `src/main/ipc/pty.ts`).
4. New PR (split item 3, startup hydration, provider contracts, no-hot listing): `hydrate-local-pty-registry.ts` and test, `daemon-pty-adapter.ts` and test, `daemon-pty-router.ts` and test, `degraded-daemon-pty-provider.ts` and test, `pty-session-id.test.ts`, `src/shared/pty-session-id-format.ts`, `src/main/providers/ssh-pty-provider.ts`, `ResourceUsageStatusSegment.tsx`, `resource-manager-session-polling.test.ts` (currently untracked), `store-session-cascades.test.ts`, and the SSH/liveness slices of `pty.ts`, `pty-connection.ts`, and `terminals.ts`.
5. New PR (split item 4, lifecycle observability): `terminal-lifecycle-diagnostics.ts` and `terminal-lifecycle-diagnostics.test.ts` (currently untracked).
6. New PR (split item 5, PTY output/IPC/perf hardening): `daemon-stream-data-batcher.ts` and test, `terminal-output-batching.test.ts`, `terminal-subscribe-buffer.test.ts`, and the pending-output-cap and replay/ACK slices of `pty.ts` and `pty-connection.ts`.
7. #7004 (xterm addon containment, outside the six split items): keep as its own PR; reset `pane-lifecycle.ts` and test from the worktree versions, and adopt fresh main's renamed WebGL test files during rebase.
8. #7006 (visible geometry, outside the six split items): keep as its own PR; any geometry hunks in `pty-connection.ts` belong to it.
9. #7007 (persisted upgrade fixtures): the gate is commandless with `protection: "none"`, so its manifest entry rides in #7001 under rule 4. Keep #7007 open only if it will deliver the actual fixture corpus and command; otherwise close it as absorbed.
10. New PR (split item 6, live local PTY E2E slice): `tests/e2e/terminal-live-pty-liveness.spec.ts` (currently untracked).
Merge order: #7001, then #7008, then #7005, then startup hydration/provider contracts, then lifecycle observability, then PTY output/IPC/perf hardening, then #7004, then #7006, then the live E2E slice. #7007 can merge any time after #7001 or be closed. The stack stays flat on #7001; each child rebases onto the merged state immediately before its own merge, and manifest conflicts are resolved by entry ownership.
If one later PR reveals a missing invariant in an earlier PR, update the earlier manifest entry or PR description before promotion rather than silently broadening the later PR's claim. The split is part of the reliability plan because reviewability, dependency clarity, and targeted rollback are themselves reliability controls.
Each split PR should follow Brennan's PR process before it is considered ready: write a scoped design doc, run delegated design review, implement from the reviewed doc, verify completeness against the original request and reliability gate, audit headline behavior, run `perf`, run the code-review/fix loop, run merge-confidence validation, open an unmerged PR, verify pushed state, and stabilize CodeRabbit/check findings. The PR text should describe that process as "Brennan's PR process"; do not mention the internal skill name in PR-facing text.
## PR Proof Contract
Every split PR must include a short proof section in its PR description before it can be treated as ready. This section should use direct evidence, not confidence language. Avoid absolute claims such as "this guarantees terminal reliability" or "this cannot regress performance." The valid claim is narrower: "this PR is safe to merge because the affected invariant, performance budget, and residual gaps were checked with the evidence below."
Required PR description sections:
- **Reliability invariant:** the class-level rule this PR protects and the recent issue, PR, or accepted gap that motivated it.
- **Material impact:** why this will meaningfully reduce escaped regressions, including which known bug class becomes harder to reintroduce.
- **Product change type:** docs/tooling, test-only gate, product hardening, diagnostics, perf hardening, or mixed.
- **Performance risk inventory:** whether the PR touches typing, focus, tab/workspace switch, visibility resume, resize, render, startup, provider listing, hidden output, PTY output, store subscribers, git/worktree scans, SSH, WSL, Windows, or mobile/relay paths.
- **No-regression evidence:** the exact count test, deterministic contract test, metric artifact, typecheck, or live run that proves the PR did not add unbounded scans, polling, hidden-pane wake loops, startup awaits, subprocess churn, renderer jank, or output buffering growth.
- **Correctness evidence:** the exact oracle and command proving the invariant. Tests that only prove implementation shape are not enough.
- **Provider/platform coverage:** local, daemon, SSH, WSL, remote-runtime, mobile/relay, macOS, Linux, and Windows must be marked covered, unaffected, or accepted-gap.
- **Residual gaps:** what this PR still does not prove. These gaps must remain in the manifest until a later PR removes them.
- **Promotion status:** whether the gate is `none`, `partial`, or `active`, and why it is not promoted beyond its current maturity.
- **Rollback or demotion rule:** what signal should cause the PR's gate to be demoted, disabled, or followed up.
Performance rule:
- A reliability PR is incomplete if it adds polling, global session listing, provider fanout, hidden-pane renderer wakeups, startup-critical awaits, repeated resize IPC, subprocess churn, unbounded output queues, broad store subscriptions, or uncapped diagnostics without a deterministic count test or metric budget.
- `pty:listSessions()` remains a global management/diagnostic operation, not a hot-path liveness primitive. Typing, focus, tab/workspace switch, visibility resume, resize, render, and per-pane liveness paths should use targeted APIs or cached ownership unless a PR provides measured proof that the broader call is safe.
- Any PR touching git/worktree scans, SSH git providers, WSL paths, cleanup, or startup repository enumeration must include the `git-crash-perf` style proof: bounded subprocesses, timeout/abort behavior, provider parity, and no startup-critical scan.
- Any PR touching terminal/session/provider/startup code must use the terminal-session reliability contract plus a performance budget. A green reliability test without a perf proof is not enough.
- Any PR that changes PTY output, IPC batching, ACK/backpressure, replay, or hidden-output behavior (split item 5 especially) must include a before/after run of an existing terminal perf artifact — `test:e2e:terminal-perf:scale:report` or the typing-latency specs — on the same machine, with both results in the PR description. Deterministic byte/count contracts prove bounds; only the perf artifact proves interactive latency did not regress.
- The gates themselves are a performance surface. Every command-backed gate must declare a runtime budget in the manifest, unit-layer gate commands should stay in single-digit seconds, and the aggregated CI job in Evidence Provenance And CI Wiring must stay inside its own wall-clock budget so reliability coverage does not degrade the development loop it protects.
Per-PR proof expectations:
1. Reliability gate manifest and plan docs.
Performance proof: no runtime product path changes. Checker runtime should stay cheap and deterministic, and manifest validation should not depend on network calls or live providers.
Materiality proof: the checker prevents overclaiming by requiring evidence metadata, existing test files, command-backed assertions, covered scope, and clear `none`/`partial`/`active` status.
2. Provider ownership and session replay safety.
Performance proof: activation and replay checks must be bounded by indexed workspace-local state, not repeated provider listing or O(records x pending-startups) scans. Tests should include repeat activation and bounded-work assertions where scans are introduced.
Materiality proof: this blocks the class where an inactive or hidden but valid provider session is mistaken for unowned and resumed again.
3. Startup hydration, provider fanout, and targeted liveness.
Performance proof: startup hydration must not block first usable UI, must expose bounded counters, and must not multiply provider/git scans across repos and worktrees without a budget. Hot interaction paths must prove zero broad `pty:listSessions()` calls.
Materiality proof: this prevents stale global snapshots or failed provider observations from closing or replacing the wrong terminal, while reducing session-listing fanout.
4. Terminal lifecycle observability.
Performance proof: breadcrumbs and traces must be capped, primitive-only, deduped or ring-buffered, and must not serialize terminal output, filesystem trees, scrollback, or large state. Tests must prove caps and sanitization.
Materiality proof: this does not itself prevent regressions, but it materially shortens future diagnosis by preserving the lifecycle facts needed to debug ownership, liveness, restore, resize, and replay failures.
5. PTY output, IPC, and performance hardening.
Performance proof: this PR must carry the strongest perf evidence in the stack: byte caps, queue caps, ACK/backpressure ordering, active-output priority, hidden-output behavior, and no unbounded renderer/main buffering. It should not become blocking until soak data shows stable runtime and no unexplained flakes.
Materiality proof: this protects the freeze/input-lag/memory-growth class where background or high-volume terminal output harms the active terminal or the app.
6. Live local PTY and E2E reliability slice.
Performance proof: the Playwright gate must be small, serial where needed, artifact-producing, and timed. It should prove the user path without becoming a broad slow/flaky suite. Runtime and flake history are required before promotion.
Materiality proof: this covers the layer that fake providers cannot prove: real Electron terminal spawn, keyboard input, process-visible output, hide/restore, resize, and cleanup.
## Factory And Skill Follow-Up
The current factory skills are close enough that the next step should be targeted edits, not a large new skill stack. The terminal-specific skill already exists, and Brennan's PR process already requires design review, perf review, code review, validation, pushed-state checks, and PR stabilization. The missing factory behavior is that reviewers and validators should explicitly fail PRs that omit the PR Proof Contract above.
Recommended skill/factory updates:
1. Update `terminal-session-reliability`.
Add the PR Proof Contract as a required output for terminal/session/provider/startup PRs. The skill should require reliability invariant, material impact, performance risk inventory, no-regression evidence, provider/platform coverage, residual gaps, promotion status, and rollback/demotion rule.
2. Update `review-code`.
Make missing proof-contract sections a review finding for P0-capable terminal/session/provider/startup/release surfaces. Reviewers should not accept "tests pass" as enough when the PR lacks a named invariant, perf budget, provider/platform matrix, or accepted-gap statement.
3. Update `brennan-test-changes`.
Require validation reports to say whether the relevant reliability gate command ran, whether the evidence proves the user-visible invariant, whether the test layer is sufficient, and whether any skipped SSH, WSL, Windows, remote-runtime, mobile, or live-Electron path is an accepted gap.
4. Update `playwright-reliability-tests`.
Add the live-terminal reliability bar directly: Playwright terminal tests must prove real input/output or visible degraded state, use deterministic event oracles instead of blind sleeps, record runtime/artifacts, and stay `experimental` until flake history supports promotion.
5. Update `perf`.
Add the PR Proof Contract's no-regression evidence language so perf reviewers require deterministic count tests or metric artifacts for polling, global session listing, provider fanout, hidden-pane wakeups, startup awaits, subprocess churn, output queue growth, and broad store subscriptions.
6. After the repo-side gate commands stabilize, update factory prompts to invoke exact gate ids and commands.
Do this after the PRs land or after their commands are stable enough to reference. Before then, the factory should require the gate class and evidence, but should not hard-code commands that may still split or rename during PR extraction.
Do not add a separate broad "terminal reliability reviewer" skill unless the existing skills fail to enforce this contract after these edits. Too many overlapping skills can make agents diffuse responsibility; the better shape is one terminal reliability contract plus required calls from design, review, perf, Playwright, and validation stages.
## Coverage Reality Check
This first stack is useful, but it must not be described as "terminal reliability is covered." The executable protection in this branch covers a deliberately small first slice of recent escaped classes:
- stale local/daemon liveness snapshots closing newer PTY bindings;
- provider-session replay ownership for several active, inactive, queued, pending, live, retained, same-session hook, and wrong-session hook records, with real hook timing through Electron still explicitly partial;
- targeted `hasPty` liveness after visibility resume instead of hot-path global `listSessions`;
- degraded daemon fallback fail-closed behavior for restored worktree-scoped and legacy/non-scoped ids;
- SSH provider listing/`hasPty` failures treated as unknown rather than destructive ownership evidence, plus mocked provider/renderer coverage for deferred SSH attach, passphrase cancellation, transient deferred reattach failure, and expired deferred relay fallback;
- remote replay coalescing, FIFO ordering, and bounded burst-tail coalescing at the renderer unit layer;
- visible geometry contract behavior for delayed split layout settle, 0x0 split-right recovery, applied-vs-requested `pty:getSize`, and resume-time size reassertion;
- boot-time local PTY registry hydration counters for repo/worktree enumeration, skipped SSH repos, adapter/session listing, registration/skips, duration, and failure phase;
- terminal lifecycle anomaly breadcrumbs recorded into renderer crash diagnostics with dedupe and compact identity fields;
- live local Electron PTY spawn, active PTY listed exactly once, focused xterm keyboard input, process-visible output, repeated workspace hide/restore of the same PTY, actual resize propagation, and exit cleanup.
Those gates should catch re-regressions in those classes, and the tests are intentionally deterministic. They mostly use renderer-unit, renderer-state, provider-contract, or fixture layers because those layers provide precise oracles with low flake risk. That is a strength for the classes they cover.
The manifest also contains commandless registered gaps for persisted-session upgrade fixtures, Windows ConPTY, startup color-query handling, and other future gates. Those entries are useful planning structure, not executable protection. Visible geometry convergence, xterm addon containment, IME/native text forwarding, runtime/mobile streams, and startup hydration counters now have first executable contract slices, but they remain `experimental` until they have repeatable CI/runtime history and red-green evidence. Output backpressure now has daemon stream and main pending-output contract slices, but the broader live perf budget remains experimental until it has repeatable metric artifacts, runtime history, and red-green evidence. The live local PTY gate now has a first executable Playwright slice that includes repeated workspace hide/restore of the same PTY, but it remains `experimental` until it has repeatable CI/runtime history and red-green evidence.
The critique that this stack is still shallow is fair. The current gates do not yet prove:
- tab-switch restore, scrollback after restore, app restart persistence, and non-local live providers;
- Windows ConPTY behavior, including shell resolution, cursor restore, rewrite repaint, CJK/IME edge cases, kitty/input protocol behavior, and activation hangs;
- terminal input latency, throughput, hidden-output pressure, renderer CPU, and store/subscriber hot paths under load;
- IME and synthetic input forwarding across macOS, Windows, Linux/fcitx/Sogou, Vietnamese, CJK, Arabic, paste, and modifier paths;
- focus recovery after browser/terminal handoffs, tab switches, app restart, and non-local provider restore;
- daemon degraded spawns, stale bootstrap, remote-runtime startup, SSH/WSL provider boundaries, and mirror polling;
- scrollback clearing/restoration correctness across hidden output, snapshot replay, and restore.
Those missing classes already appear in the full pain-points report, but this implementation plan should keep them visible so the first stack does not become false confidence.
## Fresh-Eyes Corrections
The follow-up reviews changed the plan in several concrete ways:
- Windows/IME/CJK are not covered by the current executable stack. Orca has useful unit coverage and product logic for local Windows ConPTY compatibility, including withholding Kitty keyboard protocol for local native Windows panes, but no live Windows ConPTY gate proves post-agent ordinary keys, shell parity, CJK repaint, cursor restore, resize readback, or real IME composition.
- SSH restore is better covered than the first manifest draft said, but only at store/provider/main/renderer contract layers. The current gate asserts deferred wake metadata, deferred attach/passphrase/expired-relay contracts, and unknown-liveness behavior with fake transports; it does not prove a live SSH relay, WSL, remote-runtime mirror polling, false-live visual state, or real network reconnect timing.
- Perf/backpressure is currently deterministic byte-accounting proof, not live responsiveness proof. The branch asserts daemon `write(false)`/`drain` behavior, cross-session drain priority for flush-immediate output, pure-backpressure cleanup, and main pending-output caps; it does not yet prove renderer parse pressure, event-loop delay, active key latency, or live hidden-output flood behavior.
- Daemon startup reconcile remains a mechanism, not a production-proven startup behavior. Either wire it with dry-run diagnostics and prior-worktree aliases, or keep it as an accepted gap with a named owner.
- Gate evidence is still too easy to hand-enter. The checker now prevents obvious metadata overclaims, but it does not prove the registered command was run in CI, preserve artifacts, or verify that each declared assertion executed. Add machine-checkable artifact/CI-run provenance before promoting gates to blocking.
- Manifest evidence in this worktree is not yet durable. Three referenced test files are untracked (`resource-manager-session-polling.test.ts`, `terminal-lifecycle-diagnostics.test.ts`, `tests/e2e/terminal-live-pty-liveness.spec.ts`) and roughly 39 more files carry uncommitted modifications. Until each slice is committed and pushed to its owning split branch, the manifest references proof that CI cannot run and that no one can reproduce from a ref.
- Fresh `origin/main` renamed `terminal-webgl-paste-recovery.{ts,test.ts}` to `terminal-webgl-atlas-recovery.{ts,test.ts}` and added `pane-webgl-context-recovery.test.ts` and `pane-webgl-renderer.test.ts` in #6949. The `xterm-addon.boundary-containment` gate and its documented command reference the old filename and will fail the checker after rebase; the gate should adopt the renamed and new tests during the rebase. #7133 (merged 2026-07-02) further modified `pane-webgl-renderer.{ts,test.ts}` and added `pane-reveal-repaint.{ts,test.ts}` and `terminal-visibility-resume.{ts,test.ts}`; the containment gate should evaluate those reveal-hardening tests for adoption in the same sweep. This is a confirmed instance of the stale-branch risk, and it shows the fresh-main review must include a mechanical testFiles-vs-main sweep, not only behavioral spot checks.
- This document itself drifted from the manifest it describes (a prior revision said 23 gates, 13 partial/10 none, while the manifest held 24 gates, 15 partial/9 none). Treat the manifest as authoritative and this document's counts as dated snapshots.
- The Resource Manager status segment no longer polls broad `pty:listSessions()` for its closed-popover badge in this branch. Broad session inventory is now scoped to the open Resource Manager and explicit kill/restart refresh paths; the no-hot-listing plan still needs status/diagnostic budgets and broader Electron counters before promotion.
- Boot-time local PTY registry hydration now exposes a debug snapshot and deterministic tests for retry state, SSH skip-before-worktree-enumeration, local registration counters, adapter/session listing counts, recoverable adapter-list failures, fatal hydration failures, router adapter fanout, duration, and large daemon lists. This is still only a startup-hydration counter slice; it does not prove focus, typing, tab switch, workspace switch, render, high-session provider fanout, or real Electron startup budgets.
- Terminal lifecycle anomaly diagnostics now record compact crash breadcrumbs in addition to console warnings. This is useful support evidence, not yet the full pane lifecycle trace buffer requested in the pain-points report.
## Fresh Main Architecture Findings
These findings came from focused reviews of current terminal lifecycle, renderer/input/perf, and provider/platform/startup code against freshly fetched `origin/main`. They are not all regressions introduced by the reliability-gate branch. They are the places the plan must cover before claiming broad terminal reliability.
Current coverage already exists in more places than the first gate stack showed:
- `terminal-dead-session-reconcile.test.ts`, `resume-sleeping-agent-session.test.ts`, and `pty-connection.test.ts` have substantial deterministic lifecycle coverage. A focused run of those files passed 292 tests.
- Existing E2E/perf tests include terminal typing latency, hidden TUI visual restore, long-table scroll restore, daemon slow-init PTY restore, SSH localhost, and terminal tab-switch SIGWINCH restore.
- Existing scripts include terminal perf report commands such as `test:e2e:terminal-perf`, `test:e2e:terminal-perf:scale`, `test:e2e:terminal-perf:scale:report`, and `test:e2e:terminal-perf:check-report`.
The problem is that these tests are not yet organized into reliability gates with named invariants, owners, maturity, runtime budgets, flake history, red/green evidence, and promotion rules. Some also need review before promotion because a useful regression test can still be too broad, too slow, or too flaky to be a blocking gate.
Local validation evidence from this review pass:
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \
src/renderer/src/lib/resume-sleeping-agent-session.test.ts \
src/renderer/src/components/terminal-pane/pty-connection.test.ts
```
Result on 2026-07-01: 3 test files passed, 292 tests passed, duration 6.04s. This asserts the current deterministic lifecycle/unit layer is healthy in this worktree; it does not prove the broader live/platform/provider/perf gaps.
The provider-session ownership gate now includes the queued/pending resume bridge from fresh `origin/main`: automatic resumes thread `resumeProviderSession` through `pendingStartupByTabId`, claim `automaticAgentResumeClaimsByTabId`, and treat pending startup, time-bounded runtime claims, and live agent statuses as provider-session ownership. The queued/pending claim scan is indexed once per activation so replay protection does not rescan every pending startup for every sleeping record. It also asserts live hook evidence claims only the matching provider session; a wrong-session hook cannot claim a replayed sleeping record; and stale automatic bridge claims cannot suppress recovery forever.
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/renderer/src/lib/resume-sleeping-agent-session.test.ts
```
Result on 2026-07-01: 1 test file passed, 34 tests passed, duration 7.86s. This focused command asserts repeat activation or replayed sleeping records do not launch a second resume for the same provider session while the first resume is still pending, queued pending-startup provider-session ids are read once per activation rather than once per replayed record, a live hook for the same provider session owns the replay, and a wrong-session hook does not. It does not yet prove real hook timing through Electron or a full Electron workspace activation loop.
Structural manifest validation also passed locally:
```sh
pnpm run check:reliability-gates
```
Result on 2026-07-01 after the first expansion: reliability gate manifest check passed for 22 gates. This validated the manifest schema/metadata shape only; it did not prove that the registered commands covered the full terminal runtime.
After the broader architecture review and fresh-agent review, the manifest was expanded further, including a dedicated WSL restore contract instead of burying WSL under SSH/remote coverage, and later the three convergence/escape-detection gaps registered after the #7133/#7148 saga. As of 2026-07-03, main's manifest holds 27 gates split 9 `partial` (gates whose evidence exists on main itself, including the #7133/#7148/#7173/#7192 regression tests) and 18 `none`; the pending stack raises more gates to `partial` as its slices land. These counts are dated snapshots, and the manifest itself is authoritative. The new entries are intentionally `experimental`; a few now have first executable slices, while others are still commandless gap markers. They are not active protection until each gate gets an implemented command, red/green proof, runtime evidence, and promotion history.
The manifest checker now enforces the review lessons that were easy to miss manually:
- declared `testFiles` must exist;
- every declared executable `testFiles` entry must be referenced by at least one gate command, so a manifest cannot borrow credibility from a file the registered command does not run;
- command-backed gates cannot use title selectors such as `-t`, `--grep`, or `--testNamePattern`, because title drift silently changes coverage;
- every gate must declare `protection: none | partial | active`;
- commandless gates must declare `protection: "none"`, while command-backed gates must declare at least `protection: "partial"`;
- `protection: "active"` is reserved for blocking gates with stable flake history and complete red/green evidence;
- `partial` and `active` gates must include at least one passed structured `evidenceRuns` entry whose command exactly matches the gate command;
- `none` gates must have no evidence runs, so planning gaps cannot look like tested coverage;
- `partial` and `active` gates must include `assertionRefs` that point to declared test files and name the invariant assertions inside those files;
- `none` gates must have no assertion refs, so planning gaps cannot borrow credibility from unrelated tests;
- every gate must split risk scope from covered scope with `coveredPlatforms`, `coveredProviders`, and `coverageNotes`;
- `coveredPlatforms` and `coveredProviders` must be inside the declared risk scope, and passed evidence-run platforms must appear in `coveredPlatforms`;
- `soak` and `blocking` gates must have soaking/stable flake evidence and complete red/green evidence.
How to read the manifest:
- `platforms` and `providers` describe the affected risk scope for an invariant. They do not mean the current command exercises every listed platform/provider.
- `coveredPlatforms`, `coveredProviders`, and `coverageNotes` describe the scope actually covered by current evidence. Empty `coveredProviders` on a partial gate usually means renderer/state/RPC contract coverage, not a real provider run.
- `protection: "none"` means a registered gap only; `protection: "partial"` means useful executable coverage that is not blocking protection yet; `protection: "active"` means the gate is blocking and has stable evidence.
- `evidenceRuns` are factual run records: date, runner, platform, exact command, result, duration, and summary. A local passed run supports `partial` coverage; it does not imply CI stability or flake history.
- `assertionRefs` explain which assertion groups in the listed test files carry the gate. This is especially important for shared files like `pty-connection.test.ts`, where the whole file passing is not the same as the whole file being relevant.
- `oracle`, `redGreenEvidence`, and `knownGaps` are the authoritative source for what is actually proved today.
- Commandless `experimental` gates are registered gaps, not active protection.
New or expanded manifest entries:
- `terminal-platform.live-pty-liveness`
- `terminal-platform.windows-conpty-liveness`
- `terminal-performance.no-hot-list-sessions`
- `terminal-performance.output-backpressure-budget`
- `terminal-performance.input-throughput`
- `terminal-performance.daemon-stream-backpressure`
- `terminal-performance.store-and-git-hot-paths`
- `terminal-provider.daemon-startup-degraded-contract`
- `terminal-provider.ssh-remote-reattach-contract`
- `xterm-addon.boundary-containment`
- `terminal-output.scrollback-replay-fifo`
- `terminal-output.scrollback-restore`
- `terminal-input.ime-and-synthetic-forwarding`
- `terminal-input.windows-conpty-keyboard-reset`
- `terminal-render.windows-cjk-repaint`
- `terminal-shell.windows-resolution-parity`
- `terminal-capability.startup-color-query`
- `terminal-runtime.mobile-stream-budget`
- `terminal-mirror.parser-parity`
- `terminal-observability.restore-convergence-selfcheck`
- `terminal-render.pixel-refresh-repair`
The visible geometry gate now has its first deterministic renderer/main contract slice:
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts \
src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts \
src/renderer/src/components/terminal-pane/pty-connection.test.ts \
src/main/ipc/pty.test.ts
```
Result on 2026-07-02: 4 test files passed, 481 tests passed, duration 5.98s. This focused command asserts delayed hidden split-layout settles are forwarded instead of stopping on a fixed frame budget, unmeasurable frames do not count as settled, visible 0x0 split-right panes get a nonzero recovery size while hidden background 0x0 panes are not forced to desktop size, `pty:getSize` reports applied size when a provider can expose it, and visibility resume reasserts only real PTY/xterm drift without falling back to `listSessions`. It does not yet prove shell-visible `stty`, SSH/remote geometry, Windows ConPTY geometry/readback, or full Electron geometry under real split/tab/app-restart workflows.
The live local PTY gate now has its first executable Electron slice:
```sh
pnpm run ensure:electron-runtime && npx playwright test \
tests/e2e/terminal-live-pty-liveness.spec.ts \
--config tests/playwright.config.ts --project electron-headless --workers=1
```
Result on 2026-07-02 after adding two workspace hide/restore cycles, exact active-PTY listing, and actual resize convergence: 1 Playwright test passed, test body 8.6s, full command 58.6s including build/setup. This one local macOS run asserts a real Electron local terminal binds a PTY, lists the active PTY id exactly once, accepts keyboard input through focused xterm, renders process output markers, preserves the same PTY id through repeated worktree switch-away/switch-back cycles, accepts additional keyboard input after each restore, applies an actual `pty:getSize` change after viewport resize, reports the same process-visible size, exits the probe, exits the shell, and removes the PTY id from `pty:listSessions`. It does not yet prove tab switch within the same worktree, scrollback after restore, app restart persistence, daemon, SSH, WSL, remote-runtime, Linux CI stability, or Windows ConPTY behavior.
The IME/native-text gate now has its first deterministic renderer-unit slice:
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts \
src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts \
src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts
```
Result on 2026-07-02: 3 test files passed, 63 tests passed, duration 887ms. This registers existing native-text, input-source, and paste/runtime forwarding contracts as a reliability gate slice. It does not prove real OS IME automation, Windows ConPTY post-agent keyboard reset, Arabic/RTL, JIS-yen, or the full CJK/Vietnamese/platform matrix.
The xterm addon containment gate now has its first deterministic renderer-unit slice and product hardening for core addon load failures:
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts \
src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts \
src/renderer/src/components/terminal-search-safe-find.test.ts \
src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts \
src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.test.ts
```
Result on 2026-07-02: 5 test files passed, 39 tests passed, duration 370ms. This focused command asserts a core addon `loadAddon` throw is pane-scoped and later addons still load, link provider throws degrade to no-link results, search decoration positive-integer crashes return `false` instead of escaping, and WebGL attach/refresh/recovery failures stay contained. It does not yet prove typed PTY input/output survives a live addon failure in Electron, nor WebGL dispose/reset throws across active, hidden, and resumed panes.
The runtime/mobile stream budget gate now has its first deterministic runtime-RPC slice:
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/main/runtime/rpc/terminal-subscribe-buffer.test.ts \
src/main/runtime/rpc/terminal-output-batching.test.ts \
src/main/runtime/rpc/terminal-multiplex.test.ts
```
Result on 2026-07-02: 3 test files passed, 29 tests passed, duration 2.78s. The covered contracts assert mobile initial snapshots downgrade until they fit the 512KB budget, requested binary snapshots downgrade until they fit the 2MB budget, binary live output queued while the initial snapshot is serializing stays within 256KB while preserving the newest tail, large binary output is split into 48KB-or-smaller frames, output bursts are coalesced before emission, aborted subscribes do not register stale listeners, and stale mobile resize re-stream completions are dropped. It does not yet decide JSON subscribe fallback parity/deprecation.
The replay/scrollback gate now has its first executable slice:
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/renderer/src/components/terminal-pane/pty-connection.test.ts
```
Result on 2026-07-02: 1 test file passed as part of the focused pty-connection validation, with 255 tests in that file. The relevant replay assertions cover four replay invariants: remote replay payloads that overlap before parsing starts intentionally coalesce to the latest payload, a replay whose xterm parsing has already started is not overwritten by a newer replay, multiple replay notifications accepted after parsing starts drain FIFO, and burst tails are bounded while keeping the newest snapshot. It does not yet prove fresh-main `clearBeforeReplay` metadata, metadata-only replay, or hidden-output/live-output interleaving.
The no-hot-`listSessions` gate now has its first executable slice and product hardening. The renderer/preload/main path exposes `pty.hasPty(id)` and uses targeted liveness on visibility resume and first input after resume, instead of calling global `pty:listSessions()` from that hot path. The resize-on-resume slice also asserts it uses targeted `getSize` plus `resize` rather than broad session listing. Light tab switches and visible active-state resume now assert they do not call `pty:listSessions`, `pty.hasPty`, or `pty.getSize`; a representative active-PTY case proves the scheduler hint `setActiveRendererPty` is the only allowed PTY API call there. Resource Manager broad session inventory is now scoped to the open popover instead of polling for its closed badge.
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \
src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts \
src/renderer/src/components/terminal-pane/pty-connection.test.ts \
src/renderer/src/components/status-bar/resource-manager-session-polling.test.ts \
src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
```
Result on 2026-07-02: 5 test files passed, 321 tests passed, duration 6.34s. The focused no-hot command asserts visibility resume prefers targeted `hasPty` even when `listSessions` is available, first input after visibility resume calls targeted `hasPty` once, resize re-assertion after visibility resume uses `getSize`/`resize` without `listSessions`, light tab switches and visible active-state resume avoid `listSessions`/`hasPty`/`getSize` fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, Resource Manager broad session inventory polling is scoped to the open popover, panes close only on authoritative `false`, and unknown liveness keeps panes alive. It does not yet count raw focus, workspace switch, render, high-session typing, or real provider fanout in Electron.
The store/git hot-path gate now has its first boot-hydration counter slice. This is not broad interactive perf coverage; it is the narrow startup-adjacent part that previously had no measurable budget.
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/main/memory/hydrate-local-pty-registry.test.ts
```
Result on 2026-07-02: 1 test file passed, 8 tests passed, duration 0.342s. The focused command asserts provider-unavailable hydration does not scan repos/worktrees/providers and remains retryable, SSH repos are skipped before worktree enumeration, local daemon sessions are registered with debug counters for repo/worktree/adapter/session/register/skip/duration fields, recoverable adapter-list failures are counted without failing the whole hydration pass, fatal hydration failures record failed phase and error message, router-backed hydration records multi-adapter fanout counts, pre-existing pid rows are not clobbered, and large daemon session lists hydrate without spreading into argument-limit failures. It does not yet count store recomputes, git status requests, provider listings, focus, typing, tab switch, workspace switch, render, or high-session Electron fanout.
The degraded-daemon provider contract now has its first executable slice and product hardening. Existing-session operations fail closed when ownership is unknown, and restored daemon session ids, including legacy/non-worktree-scoped ids, cannot silently spawn a local fallback PTY under the old id. Fresh degraded-mode PTY creation still intentionally routes through fallback when the caller marks it as new, and process inspection returns benign defaults for unknown ownership instead of leaking fail-closed routing errors into window-close flows.
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/main/daemon/degraded-daemon-pty-provider.test.ts \
src/main/daemon/daemon-pty-adapter.test.ts \
src/main/daemon/daemon-pty-router.test.ts
```
Result on 2026-07-02: 3 test files passed, 107 tests passed, duration 2.54s. Focused provider validation asserts discovered daemon sessions route to the daemon, fresh degraded-mode PTYs route to fallback only when marked new, router spawn discovers uncached existing sessions before choosing an adapter, router spawn fails closed instead of falling through to current when legacy ownership cannot be listed or a known session has exited, targeted `hasPty` discovery caches legacy ownership before later operations, real daemon adapter `listProcesses()` discovery seeds targeted `hasPty` liveness, folder and floating terminal workspace ids survive startup reconcile when valid, restored worktree-scoped and legacy/non-scoped ids do not fall back through spawn after ownership is lost or unknown, unknown or exited existing ids fail closed for routing operations, startup reconcile can dry-run orphan detection without killing live daemon sessions, and process inspection returns benign defaults for unknown ownership. It does not yet prove production startup reconcile wiring, prior-worktree aliases, or real daemon process restart behavior.
The SSH/remote provider contract now has its first executable provider/main/renderer slice. Provider observation failures are treated as unknown instead of destructive evidence: a failed SSH `listProcesses()` call does not clear previously learned ownership, and a rejected SSH `hasPty()` probe returns unknown liveness (`null`) rather than dead. The focused renderer/provider tests also assert that saved SSH sessions reattach through relay `pty.attach`, expired relay attach does not silently fresh-spawn inside the provider, deferred passphrase cancellation does not auto-reconnect, saved leaf/tab session ids are used after connection, transient deferred reattach failure preserves the saved session id without clearing pane/tab bindings or fresh-spawning, deferred SSH no-result cleanup clears the pending serializer without consuming the saved restore id, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn.
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/main/providers/ssh-pty-provider.test.ts \
src/main/ipc/pty.test.ts \
src/renderer/src/components/terminal-pane/pty-connection.test.ts \
src/renderer/src/store/slices/store-session-cascades.test.ts
```
Result on 2026-07-02: 4 test files passed, 546 tests passed, duration 6.48s. Focused validation asserts store wake-hint metadata for disconnected SSH relay ids, provider attach/expired-attach behavior, main-process ownership and targeted-liveness failure semantics including async provider rejection, mocked-renderer deferred SSH attach/transient-failure/expired-relay fallback, and deferred SSH no-result cleanup that clears the pending serializer without consuming the saved restore id. It does not yet prove WSL, remote-runtime mirror polling, a live SSH relay, pending-vs-attached UI status, or real network reconnect timing.
API-surface validation after adding `pty.hasPty(id)`:
```sh
pnpm run typecheck:node
pnpm run typecheck:web
```
Result on 2026-07-02: both typechecks passed.
The output-backpressure budget gate now has a deterministic provider/IPC/renderer slice:
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/main/daemon/daemon-stream-data-batcher.test.ts \
src/main/ipc/pty.test.ts \
src/renderer/src/components/terminal-pane/pty-connection.test.ts
```
Result on 2026-07-02: 3 test files passed, 469 tests passed, duration 9.35s. The focused command asserts daemon socket `write(false)`/`drain` ordering, cross-session interactive output priority, pure-backpressure cleanup, bounded daemon queued bytes, main pending renderer caps, active pending-output protection, replay/backlog slices, and ACK-gated in-flight bounds. It does not yet prove live hidden-output floods, renderer parse pressure, event-loop delay, active key latency, or full Electron perf artifacts.
Combined focused terminal/provider validation:
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/main/daemon/degraded-daemon-pty-provider.test.ts \
src/main/ipc/pty.test.ts \
src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \
src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts \
src/renderer/src/components/terminal-pane/pty-connection.test.ts \
src/renderer/src/lib/resume-sleeping-agent-session.test.ts
```
Result on 2026-07-01: 8 test files passed, 625 tests passed, duration 6.55s. This was focused terminal/provider validation, not structural manifest validation.
Reliability manifest checker validation is separate:
```sh
pnpm run check:reliability-gates
```
Result on 2026-07-02: reliability gate manifest check passed for 27 gates. This validates the manifest shape and referenced files; it does not prove the registered commands cover the full terminal runtime.
The highest-risk architecture boundaries are:
- Terminal/session ownership is spread across tab PTY ids, leaf PTY ids, last-known relay ids, pending reconnect maps, deferred SSH maps, sleeping records, and provider ownership. The plan needs a derived ownership snapshot oracle for tests/logging so attach, reattach, exit, clear, hibernate, and restore can prove all authorities agree.
- Queued or pending provider sessions must count as owned. This branch now restores the pending startup and automatic resume claim bridge and covers same-session/wrong-session hook evidence, but real hook timing and repeat activation through Electron still need coverage. This is the class-level invariant motivated by #6800.
- Stale branch risk is real. The reliability branch previously lacked some protections present on fresh `origin/main`, including queued automatic agent resume claims and exact `hasPty` missing-session reconciliation. This branch has restored those two deterministic slices; the full stack still needs final rebase/retarget verification before landing.
- Main PTY ownership historically had convenient fallback behavior for unknown ownership. This branch now hardens daemon-router and degraded-provider paths so discovered legacy ownership is cached and unknown existing-session operations fail closed, but SSH/remote/diagnostic listing paths still need live coverage.
- Startup daemon reconciliation is present as a mechanism but not wired as production startup behavior. Without it, the daemon can still have a session while the adapter no longer knows it owns that session.
- Degraded daemon routing on fresh `origin/main` can fall back to local for unknown existing-looking sessions. This branch now hardens operation plus restored worktree-scoped and legacy/non-scoped spawn paths to fail closed and registers a focused provider contract, but startup reconcile, prior-worktree aliases, and real daemon restart behavior still need coverage.
- `pty:listSessions` both observes and mutates provider ownership. This branch now asserts failed SSH observation does not clear previously learned ownership and mocked deferred SSH attach/transient-failure/expired-relay fallback behaves correctly, but WSL, remote-runtime mirror polling, live SSH reconnect timing, and false-live tab behavior remain unproved.
- Resource Manager broad session inventory is now limited to popover-open and explicit management refreshes. It still needs a status/diagnostic budget proving open-popover polling is bounded with many providers and does not become a hot-path substitute.
- Boot hydration (`hydrateLocalPtyRegistryAtBoot`) is fire-and-forget, and this branch now records startup phase counters for repo/worktree enumeration, skipped remote repos, adapter/session listing, registrations/skips, duration, and failures. It can still create startup-adjacent git subprocess and daemon-listing churn across many local repos, so do not treat startup reliability as covered until those counters are budgeted in CI/runtime evidence.
- Deferred SSH restore intentionally marks a saved PTY id before actual attach. This branch now covers the mocked renderer contract for passphrase cancellation, attach after connection, transient attach failure preserving retry state, and expired relay fallback; remaining work is false-live visual state, live SSH relay timing, and duplicate-spawn prevention under real reconnect churn.
- Geometry truth is incomplete across SSH/remote paths. Requested size, renderer size, provider-applied size, shell-visible size, and the runtime mirror's parse dimensions must be separated instead of treated as interchangeable. #7192 proved the mirror can silently stay at birth dimensions while every other authority resizes, corrupting every later restore snapshot, and that mirror reflow must stay ordered with queued output writes.
- Production diagnostics are still incomplete. This branch now records compact crash breadcrumbs for explicit terminal lifecycle anomalies, but real user reports still need a bounded pane lifecycle trace for ordinary transitions and a diagnostics-bundle artifact proving those breadcrumbs/traces are included.
The lifecycle breadcrumb gate now has its first executable slice:
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/renderer/src/components/terminal-pane/terminal-lifecycle-diagnostics.test.ts
```
Result on 2026-07-02: 1 test file passed, 2 tests passed, duration 0.593s. The focused command asserts `warnTerminalLifecycleAnomaly` preserves its console warning, records a compact `terminal_lifecycle_anomaly` renderer crash breadcrumb with terminal identity/provider/PTY/reason fields, and dedupes repeated anomalies by lifecycle identity. It does not yet prove a full pane lifecycle trace buffer, diagnostics-bundle export, or forbidden-transition assertions across live Electron/provider flows.
Specific current-source evidence checked in this pass:
- Fresh `origin/main` has queued provider-session protections in `src/renderer/src/lib/resume-sleeping-agent-session.ts`: `resumeProviderSession`, `claimAutomaticAgentResume`, and `automaticAgentResumeClaimsByTabId`.
- Fresh `origin/main` has exact liveness reconciliation in `src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts` and the `pty:hasPty` IPC handler in `src/main/ipc/pty.ts`.
- Fresh `origin/main` still uses a single `pendingReplayData` slot in `src/renderer/src/components/terminal-pane/pty-connection.ts` (re-verified after #7133 merged; #7133 changed the same file's `applyMainBufferSnapshot` alt-screen restore path, not replay draining). This branch changes replay draining to coalesce snapshots before xterm parsing starts, queue accepted snapshots FIFO once parsing is in progress, and coalesce burst tails to the newest snapshot once the in-flight queue is full. The broader scrollback gate still needs metadata-only replay, `clearBeforeReplay`, and hidden/live-output interleaving coverage.
- Fresh `origin/main` still has `DaemonPtyAdapter.reconcileOnStartup` with the explicit note that it has no production caller yet. This branch adds a dry-run mode so future startup wiring can audit would-kill sessions before destructive cleanup, but production startup-persistence remains a real gap until wired with prior-worktree aliases or tracked as an accepted gap with a gate.
- Fresh `origin/main` still lets `DegradedDaemonPtyProvider.providerFor()` fall back to local when an existing-looking session id is unmapped and not rediscovered by `hasPty`. This branch changes existing-session operation paths and restored worktree-scoped plus legacy/non-scoped spawn paths to fail closed, adds contract tests, and makes real daemon adapter `listProcesses()` discovery seed targeted `hasPty` liveness. Startup reconcile wiring and live daemon-restart validation are still required before the full provider lifecycle is covered.
- Fresh `origin/main` let the daemon router spawn an unknown existing `sessionId` on the current daemon when routing cache/discovery missed its real owner. This branch now probes/discovers all adapters first, requires `isNewSession` for intentional replacement after an authoritative exit event, and fails closed if legacy ownership cannot be listed or the known id has exited, so a stale existing id cannot silently mint over another daemon's history on current.
- Fresh `origin/main` let canonical daemon-worktree restores treat a non-minted stale/local PTY id as restorable metadata. This branch now rejects non-minted restored ids when the workspace owner is canonical (`repo::path`, `folder:<id>`, or `global-floating-terminal`), clears the stale pane/tab binding, and fresh-spawns instead of calling daemon reattach or attaching that stale id. Shared parsing also recognizes folder and floating minted ids so startup reconcile does not falsely reap valid non-repo terminal sessions.
- Fresh `origin/main` still lets `pty:listSessions` rebuild provider ownership while SSH listing failures become empty lists. This branch adds store/main/provider/renderer contracts proving disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, failed SSH observation does not clear previously learned ownership, `hasPty` failure is unknown, saved SSH sessions attach through relay `pty.attach`, deferred passphrase cancellation does not auto-reconnect, transient deferred reattach failure preserves the saved session id, deferred SSH no-result cleanup clears pending serializer state without consuming retry metadata, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn. Live SSH, WSL, remote-runtime mirror polling, false-live visual state, and real reconnect timing remain unproved.
- Fresh `origin/main` has strong Windows/unit coverage around ConPTY packaging, shell resolution, size validation, keyboard reset, complex-script classification, and Windows PTY compatibility, but the real Windows Electron/ConPTY path is still not covered as a reliable PR gate.
- Fresh `origin/main` still exposes global `pty:listSessions`; this branch now also uses targeted `hasPty` for visibility/input liveness, but gates should keep typing, focus, tab switch, visibility resume, resize, and render paths from reintroducing broad daemon/provider listing.
- Fresh `origin/main` has main-to-renderer ACK/backpressure, renderer output-scheduler caps, terminal perf scripts, and a daily/manual terminal perf workflow. This branch adds daemon stream batcher assertions that socket `write(false)` pauses further stream events until `drain`, flush-immediate output from another session drains before unrelated background backlog, global cleanup clears clients that only have backpressured writes, and queued daemon stream bytes stay bounded by preserving priority output plus the newest tail under sustained pressure; it also caps main pending renderer output per PTY and in total, trims background pending output before active pending output under total pressure, and asserts `seq`/`rawLength` metadata stays correct for the surviving tail. Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved.
- Fresh `origin/main` has binary runtime/mobile terminal stream caps, but the legacy JSON subscription path has weaker buffering/backpressure guarantees. Either gate both paths or explicitly deprecate/accept the JSON gap.
Existing E2E/perf tests that should be reviewed for promotion:
- `tests/e2e/daemon-slow-init-pty-gate.spec.ts`: strong daemon live-session restore signal; already has a tight user-visible invariant.
- `tests/e2e/daemon-live-session-preservation.spec.ts` and `tests/e2e/daemon-slow-health-check-preservation.spec.ts`: likely useful daemon liveness/preservation gates after oracle and flake review.
- `tests/e2e/terminal-typing-latency.spec.ts`, `tests/e2e/terminal-codex-local-typing-latency.spec.ts`, and `tests/e2e/terminal-history-size-typing-latency.spec.ts`: useful perf signals, but should be tied to stable budgets and artifacts before blocking promotion.
- `tests/e2e/terminal-output-scheduler.spec.ts`, `tests/e2e/artificial-opencode-terminal-load.spec.ts`, and hidden-pressure scenarios: useful for output/backpressure, but should become metric-gates rather than visual-only broad E2Es.
- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts`, `tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts`, and `tests/e2e/terminal-tab-switch-visual-restore.spec.ts`: useful rendering recovery coverage; must keep deterministic text/buffer oracles and limit screenshots to diagnostics.
- `tests/e2e/terminal-long-table-scroll-restore.spec.ts` and `tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts`: useful scrollback/rendering regression evidence, but they contain timed waits and should be reviewed before blocking promotion.
- `tests/e2e/ssh-localhost.spec.ts`, `tests/e2e/ssh-docker-relay-perf.spec.ts`, and SSH Codex replay/reconnect specs: useful SSH coverage, but should be separated into deterministic provider contracts plus a small number of environment-dependent live SSH soaks.
- `tests/e2e/terminal-windows-shell-paste-ownership.spec.ts` and Windows env/icon tests: useful Windows path coverage, but not a substitute for a Windows ConPTY live liveness gate.
Promotion split for existing E2Es:
- Promote after soak: a smaller derivative of `terminal-typing-latency.spec.ts` as the live local PTY input/output gate.
- Promote after soak: daemon preservation specs, especially slow-init and stale launch identity, as daemon warm-reattach gates.
- Promote targeted: paste ownership tests where the oracle is visible output plus exactly-one PTY write.
- Keep in soak/nightly: artificial OpenCode load, terminal scale perf report, hidden real-PTY pressure, and SSH Docker perf.
- Keep as release evidence: raw emoji/golden rendering, long-table rendering, WebGL recovery, and Codex/OpenCode artifact repros.
- Rewrite before blocking: column desync specs need resize-applied acknowledgement or an in-PTY `SIGWINCH` marker instead of post-resize sleeps.
- Rewrite before blocking: SSH Codex artifact repros and headful dead-terminal repros need deterministic fixtures/events instead of long waits and screenshots as primary proof.
The first live PTY gate is deliberately small:
- File: `tests/e2e/terminal-live-pty-liveness.spec.ts`.
- Command: `pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-live-pty-liveness.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1`.
- Invariant: a newly opened local terminal has one active PTY id listed exactly once, accepts real keyboard input through focused xterm, delivers process output visibly, keeps the same PTY id through repeated workspace hide/restore cycles, accepts input after restore, applies an actual size change, and exits without leaving a stale binding.
- Oracle: start a deterministic Node raw-mode probe, wait for `LIVE_PTY_READY_<runId>`, require the active PTY id to appear exactly once in `pty:listSessions`, type through `.xterm-helper-textarea`, require ordered per-key markers in serialized terminal content, switch to another worktree and back twice, require the active pane to keep the original PTY id, type again after each restore, resize and require `pty:getSize` to change plus a matching process-visible size marker, then exit the probe and shell and require the old PTY id to disappear from `pty:listSessions`.
- Wait rule: no blind sleeps. Wait only on PTY binding, active worktree changes, ready marker, key markers, resize marker, and list-session absence.
- Budget: target p95 under 75s on Linux including e2e build/setup, hard test timeout 90s. Start blocking on Linux only after soak; run macOS/Windows nightly until each platform has its own history.
- Failure artifacts: PTY id, provider classification, pid if available, size snapshots, key latencies, final `listSessions`, trace, and screenshot.
- Red/green proof: intentionally break xterm focus, PTY write forwarding, resize forwarding, and exit cleanup in separate local runs and confirm the gate fails for each class.
- Local run on 2026-07-02: 1 Playwright test passed in 58.6s total, with an 8.6s test body, on this macOS worktree. This is one green local run for the local live PTY path, not enough flake/runtime history for blocking promotion.
## Gate Coverage Matrix
This is the intended shape after rebasing the stack. The first two columns are what we have now; the last two are what must be added or promoted before the plan earns broader reliability claims.
| Reliability class | Existing evidence | Gap | Next gate or action |
| --- | --- | --- | --- |
| Stale liveness snapshots closing newer PTYs | `terminal-session.snapshot-freshness`; deterministic unit coverage | No full Electron tab-survival/input echo proof | Add `terminal-platform.live-pty-liveness` and keep snapshot gate as lower-layer guard |
| Provider-session replay ownership | `agent-session.provider-ownership`; renderer state tests; queued/pending resume claim proof; bounded queued-claim index proof; same-session and wrong-session hook proof | Needs real Electron repeated activation/hook timing and bounded hook/status scan assertions if those grow | Add Electron activation proof |
| Exact PTY liveness | Fresh main has targeted `hasPty` path | Current branch must not lose it; provider failures must be unknown, not dead | Rebase/restore exact liveness reconciliation and register it as a gate |
| Store/session ownership coherence | Many focused tests touch pieces | No single derived oracle across tab, leaf, relay, reconnect, deferred SSH, and sleeping maps | Add ownership snapshot helper for tests/logging |
| Terminal lifecycle diagnostics | `terminal-observability.lifecycle-breadcrumbs` now records compact anomaly breadcrumbs into crash diagnostics | No full pane lifecycle trace buffer or diagnostics-bundle artifact proof | Add bounded trace buffer and bundle artifact test |
| Xterm addon/search/WebGL containment | `xterm-addon.boundary-containment`; deterministic core addon load, link-provider, search, and WebGL containment tests; #7054 hidden TUI product hardening | Needs live Electron typed input/output survival after addon failure, and WebGL dispose/reset throw coverage across active/hidden/resumed panes | Add focused component/live input echo follow-up; promote hidden TUI E2E after flake review |
| Visible terminal geometry | #7006 renderer/provider-style gate; #7192's merged mirror-resize and reflow-ordering red tests on main | SSH/remote and shell-visible truth not fully covered; mirror-geometry slice unregistered until rebase | Add live PTY size readback and provider-applied-size contract; register #7192's mirror-authority tests |
| Persisted session upgrade | #7007 fixture gate | Needs broader old-version corpus and daemon/SSH cases | Build immutable fixture corpus with startup timing |
| Live local PTY lifecycle | `terminal-platform.live-pty-liveness` now has a focused Electron Playwright slice covering local spawn, single active PTY listing, xterm input, PTY output, repeated workspace hide/restore, actual resize propagation, and cleanup | Needs flake/runtime history, red-green evidence, tab switch, scrollback after restore, app restart persistence, and non-local provider coverage | Promote first slice after soak; add tab-switch/scrollback/restart follow-ups |
| Windows ConPTY lifecycle | Recent fixes on main, some unit/platform logic | No required Windows live gate for ConPTY echo/resize/cursor/CJK/IME | Add `terminal-platform.windows-conpty-liveness` |
| Windows keyboard reset | Unit coverage and recent #6999/#6858 fixes | No live proof that Enter/Backspace/Arrow return to normal after agent/TUI exit | Add `terminal-input.windows-conpty-keyboard-reset` |
| Windows CJK/repaint | Complex-script and compatibility tests plus recent CJK repaint fixes | No Windows visual/pixel oracle for wide-glyph in-place redraws | Add `terminal-render.windows-cjk-repaint` |
| Windows shell parity | Shell resolution unit coverage and packaging fixes | No local-vs-daemon parity gate for PowerShell 5/7, cmd, Git Bash, WSL, startup command delivery | Add `terminal-shell.windows-resolution-parity` |
| Input latency/perf | Existing terminal perf scripts and typing-latency spec | Not registered, not tied to budgets/owners/promotion | Add `terminal-performance.input-throughput` |
| Hot interaction listing | Recent #7002 fix, targeted `hasPty` path, deterministic visibility/input/resize no-listing assertions, light tab/active-state resume no-provider-fanout assertions, and closed Resource Manager broad-listing avoidance | No count gate proving `pty:listSessions` stays out of raw focus/workspace-switch/render/high-session Electron scenarios; open Resource Manager polling still needs an explicit budget | Extend `terminal-performance.no-hot-list-sessions` |
| Daemon stream backpressure | Batching/chunking plus new batcher contract proof for `write(false)`/`drain`, cross-session flush-immediate drain priority, pure-backpressure cleanup, and bounded queued tails | No live hidden-output flood proof or active input latency artifact | Extend `terminal-performance.output-backpressure-budget` |
| Renderer/main ACK and backlog | Main ACK gates, renderer scheduler caps, focused unit coverage, and bounded main pending tails exist | No live metric artifact tying hidden restore, active key latency, queued chars, and dropped backlogs to promotion | Extend `terminal-performance.output-backpressure-budget` |
| Runtime/mobile terminal stream | `terminal-runtime.mobile-stream-budget`; runtime-RPC tests assert mobile initial snapshot <=512KB, requested binary snapshot <=2MB, pending live output <=256KB while snapshot loads, output frames <=48KB, output coalescing, abort cleanup, and stale resize re-stream suppression | Legacy JSON path parity/deprecation is undecided | Decide/gate/deprecate JSON fallback |
| Store/git hot paths around terminal changes | Perf/git-crash skills exist; git status limits/coalescing exist; `terminal-performance.store-and-git-hot-paths` now has a boot-hydration counter slice | No Electron count gate for terminal-adjacent store projection, git status, focus, typing, tab/workspace switch, render, or high-session provider fanout | Extend `terminal-performance.store-and-git-hot-paths` with interactive counters |
| IME and synthetic input | Input forwarding code and scattered coverage | No representative matrix or byte/cell oracle | Add `terminal-input.ime-and-synthetic-forwarding` |
| Daemon degraded/startup provider contract | Mechanisms exist; daemon slow-init E2E helps | Startup reconcile is unwired; degraded fallback needs fail-closed/probe proof | Add daemon degraded and startup reconcile contract gates |
| SSH/remote restore and mirror polling | SSH localhost and remote fixes exist; this branch has store/provider/main/renderer proof for deferred SSH wake metadata, SSH attach, listing failure, deferred attach, passphrase cancellation, transient deferred reattach failure, and expired deferred relay fallback | Remote mirror polling, live SSH relay behavior, false-live visual state, pending-vs-attached UI status, and real reconnect timing remain unproved | Add live SSH soak and remote mirror polling gates |
| WSL restore and launch identity | `terminal-provider.wsl-restore-contract` is now registered as a first-class gap | No WSL provider contract or live Windows WSL smoke yet proves cwd/path identity, startup command delivery, targeted liveness, or restore ownership | Add deterministic WSL provider contract, then one focused Windows WSL smoke |
| Scrollback/replay restore | Recent #7012 fix, long-table restore coverage, this branch's remote replay ordering proof, #7133's merged alt-only-clear tests, and #7173's merged ordered-seq interleaving and session-revival tests on main | Normal-buffer clear semantics and metadata-only replay still need deterministic lower-layer proof; the merged #7133/#7173 tests are unregistered until rebase | Add `terminal-output.scrollback-restore` as a dirty-state exactness contract, seeded with #7133's and #7173's tests |
| Renderer/mirror parser parity | #7148 (merged 2026-07-02) fixed the first known width divergence; its red-green width test exists on main | Parser configs can still drift silently on any axis besides widths; no parity gate command is registered | Add `terminal-mirror.parser-parity` built on the shared `terminal-unicode-provider.ts`/`pane-terminal-options.ts` seams |
| Post-restore convergence detection in production | Anomaly-breadcrumb machinery exists in this branch | Nothing measures renderer-vs-mirror divergence after real restores; #7133-class corruption stays silent until a user notices | Add `terminal-observability.restore-convergence-selfcheck` probe plus telemetry |
| Stale pixels after reveal (render-level) | #7133's reveal hardening and the live repro harness's refresh-repair oracle | Buffer oracles are blind to buffer-clean, pixels-stale variants by definition | Add `terminal-render.pixel-refresh-repair` to the release-blocking terminal-rendering-golden suite |
## Convergence Oracles And Escape Detection
The #7133 saga is the design input for this section. One user-visible symptom — stale terminal frames on worktree return — decomposed into five distinct mechanisms: the skipped alt-screen clear (#7133 primary fix), the WebGL render-model fossil (#7133 hardening), the daemon mirror unicode width divergence (#7148), the stale hidden-chunk ordering hole with seq-restart data loss (#7173), and desktop resizes never reaching the runtime mirror (#7192). None of the three was predictable in advance, and seventeen faithful fresh-window repro attempts missed the class because the bugs only exist in dirty, long-lived pane states. Mechanism-enumeration testing cannot catch this family. Layer-convergence assertion can.
Orca holds four copies of what a terminal shows: the PTY application's intended screen, the main-process headless mirror buffer, the renderer xterm buffer, and the WebGL-rendered pixels. Every bug in this family is two adjacent copies silently disagreeing. The strategy is two layers that map to the program's two goals — catching issues nobody predicted, and catching regressions to issues already fixed.
Catch novel escapes:
1. `terminal-observability.restore-convergence-selfcheck`: after every hidden-to-visible reveal settles — whether or not a restore was triggered — compare a bounded row-hash sample of the renderer buffer against the current main-mirror state; on mismatch, record a compact content-free `terminal_lifecycle_anomaly` breadcrumb (machinery already in this branch) and count it in telemetry. Per-reveal scope is load-bearing: #7173's frozen-output variant restores faithfully and then silently drops later hidden output, so a restore-scoped probe would never fire. This turns silent corruption into a measurable signal — #7133 and #7173 would have appeared in anomaly counts weeks before the user reports — and after each fix the anomaly rate is the in-production regression detector. Budget: one bounded comparison per reveal, no polling, no content capture beyond hashed row samples.
2. `terminal-render.pixel-refresh-repair`: the render-level variant is invisible to every buffer oracle by definition (buffer clean, pixels stale). The content-independent oracle: screenshot the revealed pane, force a full model-invalidating repaint, screenshot again; a material diff means something rendered stale, whatever the mechanism. Productize the existing live repro harness with this oracle plus a JS-level render-model-vs-buffer comparison, and land it in the release-blocking `terminal-rendering-golden` suite that `release-cut.yml` already runs against every release tag. Use nightly runs only as the interim phase to stabilize the per-platform diff threshold; golden-set membership is what makes "the next release does not ship this class" directly checkable.
3. Session-corpus replay: record real agent-session byte streams (a Claude run is exactly the #7133 shape — banner, prompt echo, final frame with blank regions) as fixtures and replay them through the hide/skip/restore machinery under the convergence oracles. Real corpora contain escape-sequence conjunctions nobody writes by hand. This is fixture strategy for the gates above, not a separate gate.
Catch regressions to known fixes:
4. `terminal-mirror.parser-parity`: feed identical byte corpora to the renderer xterm configuration and the main headless mirror and assert cell-identical buffers, with both parsers constructed from one shared configuration module. #7148's divergence (Unicode 11 + ZWJ in the renderer, default width tables in the mirror) existed since the mirror was born and was catchable by a millisecond-scale unit test; this gate also blocks the next one-sided config drift.
5. The dirty-state restore exactness contract as `terminal-output.scrollback-restore`'s command: apply snapshots onto adversarially dirty panes and assert the resulting buffer exactly equals the snapshot frame. Exactness is load-bearing — presence-of-marker oracles pass on merged frames. Seeded with #7133's merged alt-only-clear tests and #7148's positioned-overwrite width oracle.
6. Manifest registration of every escape: each fixed escape's red-green test becomes gate evidence, and per the Operating Rules the fix PR must also strengthen the relevant convergence oracle, so each escape permanently shrinks the space for the next one.
Seam placement is part of the design: convergence must be asserted at every adjacent pair of copies, not at one seam. #7192 proved this the hard way — a renderer-vs-mirror check is blind to corruption inside the mirror itself, because the restore is perfectly faithful to a corrupted source. That is why the geometry gate owns the runtime mirror's dimensions and reflow ordering as a first-class size authority, upstream of the seam this section's self-check watches.
The honest limit stands: this shrinks the escape space rather than zeroing it. The first four mechanisms in the saga are caught by items 1, 2, 4, and 5 without predicting any of them; the fifth (#7192) escaped every renderer-side oracle as originally specced and was closed by adding the mirror to the geometry authority list — evidence that the escape-driven loop (item 6) is not optional. The three new gates and the strengthened `terminal-output.scrollback-restore` entry are registered in the manifest as `protection: "none"` gaps, so the checker, factory reviews, and this plan cannot claim them as coverage before their commands exist.
## Immediate Fix Candidates
These are the concrete pieces to prioritize before declaring the plan implemented:
Three mechanical prerequisites gate everything below:
- Materialize the split: commit each working-tree slice to its owning branch per the file assignment in the PR Split Plan, reset the diverged child branches (#7004, #7005, #7006, #7008) from the worktree versions of their files, and push. Until then, every evidence run in this plan and in the manifest refers to unreproducible state.
- During the rebase, run the testFiles-vs-fresh-main audit and fix the confirmed #6949 WebGL rename by adopting `terminal-webgl-atlas-recovery.test.ts` and evaluating `pane-webgl-context-recovery.test.ts` and `pane-webgl-renderer.test.ts` for the containment gate.
- Open the aggregated non-blocking `reliability-gates` CI job so promotion evidence starts accruing with machine provenance.
1. Rebase or retarget the existing reliability PR stack onto fresh `origin/main`, then verify the stack still contains queued automatic resume claims and exact `hasPty` missing-session reconciliation. This branch has restored both deterministic slices, but final stack verification is still required.
2. Extend the provider-session ownership test from the current queued/pending, bounded-index, same-session hook, and wrong-session hook proof to a real Electron repeated-activation test, plus bounded-work assertions for any new hook/status scans.
3. Add or restore exact liveness tests: `hasPty(id) === false` tears down only the matching local PTY, while `true`, `null`, rejection, SSH/remote unknown states, and stale pre-bind responses do not close panes.
4. Add a small derived terminal ownership snapshot helper for tests and diagnostics. It should report whether each live PTY is represented once by the intended tab/leaf and absent from stale maps after attach, reattach, exit, clear, hibernate, and restore.
5. Keep the replay FIFO/burst fix and tests in the stack, rebased over #7133's alt-clear restore preamble in the same file. This branch now asserts that multiple replay notifications arriving during an async drain cannot overwrite one another, cannot reorder accepted replay snapshots, and cannot grow an unbounded replay queue under bursty snapshots; remaining replay work is normal-buffer clear semantics (alt-screen clear landed in #7133), metadata-only replay, and hidden/live-output interleaving.
6. Wire `DaemonPtyAdapter.reconcileOnStartup`, or explicitly create a provider-contract gate and accepted gap if wiring is not safe yet.
7. Keep the degraded daemon fail-closed contract in place and extend it to startup/restart coverage. This branch now prevents unknown existing ids from silently routing operation calls or restored worktree-scoped and legacy/non-scoped spawn calls to fallback; the remaining work is production startup reconcile, prior-worktree aliases, and live daemon-restart validation.
8. Keep the daemon-router targeted-discovery fix in place. This branch now caches ownership discovered by `hasPty()` before later write/resize-style operations and fails closed for operations on unknown existing session ids, preventing legacy-daemon sessions from silently routing to the current daemon.
9. Extend provider listing failure contract tests. This branch now asserts disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, a rejected SSH listing does not clear previously learned ownership, thrown SSH `hasPty` is unknown, provider expired attach does not silently fresh-spawn, deferred SSH passphrase cancellation does not reconnect, deferred attach uses saved session ids, transient deferred reattach failure preserves retry metadata instead of fresh-spawning, and expired deferred relay fallback clears stale pane/tab bindings before one fresh spawn. Remaining work is empty-but-ambiguous provider snapshots, WSL, remote-runtime mirror polling, live SSH relay timing, false-live visual state, pending-vs-attached UI status, and pane-close behavior.
10. Extend the new lifecycle anomaly breadcrumbs into a bounded structured lifecycle trace outside E2E-only mode for reattach, expired sessions, provider-list failure, unknown ownership, fallback routing, replay, and terminal input recovery.
11. Extend the hot-interaction listing count gate. The targeted `hasPty`, resize-on-resume, light tab/active-state resume, and closed Resource Manager polling slices are implemented; next it should prove `pty:listSessions()` calls are zero during raw focus, workspace switch, render, high-session Electron scenarios, and separately budget open status/diagnostic polling.
12. Add a cheaper Resource Manager session-status path or an explicit budget for open-popover `pty:listSessions()` polling. The closed-popover poll is removed, but the visible Resource Manager inventory still fans out to providers and mutates ownership, so it should have a measured ceiling.
13. Budget the new startup hydration counters for `hydrateLocalPtyRegistryAtBoot` in CI/runtime evidence, including repo/worktree enumeration count, git subprocess count proxy, daemon session-list duration, and confirmation that no startup-critical path awaits the scan.
14. Extend the output backpressure proof. The daemon stream batcher now pauses after `write(false)`, prioritizes flush-immediate output from another session ahead of unrelated background backlog on `drain`, clears pure-backpressure queues on global cleanup, bounds queued stream bytes, and resumes on `drain`; main pending renderer output is capped per PTY and in total. Next, hidden-output floods and active key latency need metric artifacts under the perf budget.
15. Add Windows platform gates rather than relying on Linux/macOS live PTY evidence: ConPTY liveness, keyboard reset, CJK repaint, shell resolution parity, and IME/native-text forwarding.
16. Decide whether the legacy JSON runtime terminal subscribe path is supported. If supported, gate its snapshot/live-output byte caps; if not, mark it as an accepted gap with a deprecation path.
17. Implement the dirty-state restore exactness contract as `terminal-output.scrollback-restore`'s first command, seeded with #7133's merged alt-only-clear tests and #7173's ordered-seq interleaving and session-revival tests: apply snapshots onto already-alt-screen panes with stale content in cells the new frame leaves blank, include revived sessions with restarted PTY seq counters in the dirty-state matrix, and assert exact buffer equality.
18. Implement the parser-parity seed during the rebase, now that #7148 has merged: register `headless-emulator-unicode-width.test.ts` plus a shared parser-construction assertion over `terminal-unicode-provider.ts` and `pane-terminal-options.ts` as `terminal-mirror.parser-parity`'s first command. The test file exists on fresh main but not at this branch's stale merge-base, so the command cannot be registered before the rebase.
19. Implement the per-reveal convergence self-check behind the existing anomaly-breadcrumb machinery — comparing the renderer buffer against current mirror state on every reveal, not only after restores, with a strict per-reveal budget and no content capture — and productize the stale-render repro harness into `terminal-render.pixel-refresh-repair` targeting the release-blocking terminal-rendering-golden suite.
20. Register #7192's merged mirror-geometry red tests as the mirror-authority slice of `terminal-geometry.visible-convergence` during the rebase, covering both accepted-resize fan-out to the runtime mirror and reflow-vs-queued-output ordering.
Next gates to add before claiming broad terminal reliability coverage:
1. `terminal-platform.live-pty-liveness`: first focused Electron/live-PTY slice is implemented for local Linux/macOS-style terminals. One local macOS run asserts bind, single active PTY listing, focus, keyboard input, PTY echo, repeated workspace hide/restore of the same PTY, post-restore input, actual size propagation, and exit cleanup. Follow up with scrollback-after-restore, tab switch, app restart, and non-local providers before broad live-terminal claims; keep it `experimental` until it has flake/runtime and red-green evidence.
2. `terminal-platform.windows-conpty-liveness`: Windows-specific gate for ConPTY spawn/activation, PowerShell resolution, cursor/rewrite repaint, CJK/IME bytes, and no stuck `stale_bootstrap`. Use lower-level provider contracts where possible and one focused Windows Electron smoke for the real ConPTY path.
3. `terminal-input.windows-conpty-keyboard-reset`: Windows local ConPTY gate proving Enter, Backspace, Arrow, paste, and ordinary shell submission behave normally after an agent or TUI exits. Oracle should include PTY input logs proving stale Kitty/CSI-u mode is not still applied to standard keys.
4. `terminal-render.windows-cjk-repaint`: Windows local ConPTY wide-glyph redraw gate. Oracle should combine xterm buffer/text evidence with screenshot/canvas/pixel evidence and a bounded refresh count.
5. `terminal-shell.windows-resolution-parity`: local and daemon providers must resolve equivalent shell path, args, cwd, env, startup command delivery, and fallback behavior for PowerShell 5/7, cmd, Git Bash, WSL, and missing `pwsh`.
6. `terminal-performance.input-throughput`: non-blocking perf gate for typing latency, hidden-output pressure, renderer CPU, terminal output throughput, resize churn, and store subscriber work. This should be metric-based, not screenshot-based.
7. `terminal-performance.no-hot-list-sessions`: deterministic count gate proving `pty:listSessions()` is not called from typing, focus, tab/workspace switch, visibility resume, resize, render, or per-pane liveness paths.
8. `terminal-performance.daemon-stream-backpressure`: provider/daemon contract gate proving daemon socket writes respect backpressure under output floods and do not starve active input.
9. `terminal-performance.output-backpressure-budget`: main-to-renderer ACK and renderer scheduler budget gate. Suggested ceilings: peak renderer in-flight bytes <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped renderer backlogs 0 in normal perf scenarios, and hidden restore <=1000ms.
10. `terminal-runtime.mobile-stream-budget`: runtime/mobile stream gate proving binary terminal snapshots stay <=512KB mobile or <=2MB requested, live pending output stays <=256KB while snapshot loads, chunks stay <=48KB, and batches stay <=64KB.
11. `terminal-input.ime-and-synthetic-forwarding`: representative IME/input matrix with deterministic byte/cell oracles at the lowest reliable layer, plus platform-specific manual/soak coverage where real IMEs are required.
12. `terminal-provider.remote-daemon-contract`: provider contract for daemon degraded spawn, SSH/remote unknown liveness, mirror polling, reconnect, and path/cwd authority.
13. `terminal-provider.wsl-restore-contract`: Windows WSL provider contract proving host/guest cwd identity, shell launch args, startup command delivery, targeted liveness semantics, and restore ownership, followed by one focused live WSL smoke.
14. `terminal-output.scrollback-restore`: deterministic scrollback/snapshot/replay gate for hidden output, restore, clearing, and no cross-tab or stale-output overlap. Seed it with #7133's merged assertions — the alt-only clear preamble written before alt-screen snapshot data (and no `3J`) in `pty-connection.test.ts`, plus the reveal-repaint ordering tests — then extend to normal-buffer clear semantics, metadata-only replay, and hidden/live-output interleaving. #7133's buffer-merge escape (pre-hide frame bleeding through blank cells) is the motivating regression for this gate.
15. `terminal-capability.startup-color-query`: startup OSC 10/11 replies are answered out of band at startup only, do not leak into shell/renderer streams, and ordinary OSC color queries remain renderer-handled.
16. `terminal-mirror.parser-parity`: renderer xterm and the main headless mirror parse identical byte corpora into cell-identical buffers, with both parsers constructed from one shared configuration module. Seed with #7148's merged width test (`headless-emulator-unicode-width.test.ts`) plus a shared-construction assertion over `terminal-unicode-provider.ts` and `pane-terminal-options.ts`; extend with recorded agent-session corpora.
17. `terminal-observability.restore-convergence-selfcheck`: production per-reveal probe comparing a bounded row-hash sample of the renderer buffer against current main-mirror state — on every reveal, not only after restores, because #7173's frozen-output variant never triggers another restore — recording deduped content-free anomaly breadcrumbs and telemetry counts. This is the in-production escape detector for the whole restore family; corruption upstream of the mirror itself is owned by the geometry gate.
18. `terminal-render.pixel-refresh-repair`: content-independent refresh-repair oracle (screenshot, force a model-invalidating repaint, screenshot, bounded diff) plus a render-model-vs-buffer comparison, productized from the existing stale-render repro harness into the release-blocking `terminal-rendering-golden` suite; nightly runs only until the diff threshold is stable.
Specific near-term hardening before promotion:
- #7008 now includes a bounded queued-claim assertion plus same-session and wrong-session hook cases for provider-session ownership checks. It should still be followed by a real Electron repeated-activation proof and any additional bounded-work assertions if hook/status ownership scans grow.
- Each gate PR should keep its residual gaps in the PR description and manifest until a real follow-up gate removes them. Do not let a green lower-layer gate imply live Electron, SSH, WSL, Windows ConPTY, or IME coverage.
Promotion rule for these additions is unchanged: do not make any of them blocking until they have red/green proof, stable runtime history, zero unexplained flakes in the promotion window, a clear owner, and a demotion rule. A non-blocking gate is useful evidence and documentation; it becomes protection only after promotion criteria are met.
## Goal
Prevent the recent terminal, tab, session, startup, provider, and performance regressions from escaping again by turning each high-risk reliability class into a small executable gate with explicit maturity, owner, runtime budget, flake history, and promotion evidence.
This plan is broad in scope but staged in rollout. The first milestone exercises the operating model end to end; later milestones add live Electron, Windows ConPTY, SSH/remote, daemon, IME, scrollback, and performance gates without turning the suite into noisy sprawl.
## Operating Rules
- Add gates only when they protect a named invariant tied to a real issue, PR, or accepted gap.
- Prefer deterministic unit or provider-contract tests over Electron UI tests when the lower layer proves the user-visible invariant.
- Never promote a gate to blocking until it has red/green evidence, stable runtime evidence, and a named demotion rule.
- Keep stress/torture runs non-blocking unless they have deterministic oracles and flake history.
- Include a performance or crash-safety budget whenever the protected change touches terminal throughput, hidden panes, persistence, startup, polling, subprocesses, git/worktree scans, SSH, WSL, or Windows paths.
- Restore, replay, reattach, and snapshot gates must start from adversarially dirty initial states — already-alt-screen panes, stale content occupying cells the new frame leaves blank, scrollback present, wide/emoji glyphs — never only fresh terminals. Fresh-state-only testing is why the #7133 class survived seventeen faithful repro attempts.
- Restore and replay oracles must assert frame exactness or stale-content absence. Marker-presence oracles pass on merged frames and are not acceptable correctness evidence for restore paths.
- Every escaped terminal bug's fix PR must ship both the narrow mechanism regression test and a strengthening of the relevant convergence oracle (parser parity, restore exactness, convergence self-check, or pixel refresh-repair), so each escape permanently shrinks the space for the next one.
## Milestone 1
Milestone 1 creates the reliability-gate loop with the two highest-leverage escaped classes:
1. `terminal-session.snapshot-freshness`
A stale local/daemon liveness snapshot cannot close a newer PTY binding.
2. `agent-session.provider-ownership`
Workspace activation, restore, sleep, hibernate, dedupe, clearing, or reconnect code cannot replay or resume a provider session already owned, queued, pending, or live in the workspace.
Both gates already have targeted tests in the repo. Milestone 1 makes them reviewable factory artifacts by registering their invariant, command, owner, maturity, known gaps, and promotion criteria in `config/reliability-gates.jsonc`.
## First Commands
Run the structural manifest check:
```sh
pnpm run check:reliability-gates
```
Run the first two gate tests:
```sh
pnpm exec vitest run --config config/vitest.config.ts \
src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \
src/renderer/src/lib/resume-sleeping-agent-session.test.ts
```
Run the existing terminal perf report gate before promoting terminal lifecycle gates to blocking:
```sh
pnpm run test:e2e:terminal-perf:scale:report
```
## Promotion Criteria
A gate can move from `experimental` to `soak` when:
- it is registered in the manifest;
- it has a deterministic oracle with no blind sleeps;
- it has a cheap command that can run repeatedly;
- it names the failure artifact reviewers should inspect;
- it has a clear owner and demotion rule.
A gate can move from `soak` to `blocking` when:
- it has red/green evidence against the old behavior, a regression fixture, or an intentionally broken variant;
- it has at least 100 consecutive passing soak runs or 14 days of required-platform CI history, whichever is more appropriate for the gate;
- that history comes from the aggregated `reliability-gates` CI job with machine-recorded results (see Evidence Provenance And CI Wiring), not from hand-entered local runs;
- p95 runtime is within the manifest budget;
- there are zero unexplained flakes in the promotion window;
- any required perf/git-crash budget has measured evidence.
For visual and live release-assurance gates, `blocking` means membership in the release-blocking `terminal-rendering-golden` suite that `release-cut.yml` runs against every release tag — the existing golden rail, not a new checkpoint mechanism, and never PR-blocking. Deterministic unit and contract gates stay on the PR path, where catching a regression is strictly cheaper than catching it at release time.
The manifest also allows `accepted-gap` for explicitly deferred reliability work with a named owner and `deprecated` for gates superseded by a stronger invariant or removed product surface. Those levels should stay visible in the manifest so factory review can distinguish intentional gaps from missing coverage.
## Factory Contract
`brennan-yolo-lite`, `review-code`, `perf`, and `git-crash-perf` should treat the manifest as the source of truth.
For any PR touching a P0-capable surface, the agent or reviewer should:
- identify the touched reliability class;
- name the matching manifest gate or accepted gap;
- run the gate command or explain why it does not apply;
- require a new manifest entry when the PR introduces a new reliability class;
- invoke `perf` or `git-crash-perf` when the touched surface matches their risk scope.
## Evidence Provenance And CI Wiring
Hand-entered evidence is the weakest link in this plan: the checker validates metadata shape, but nothing yet proves a registered command actually ran against the code it claims to cover. Reliability gates become protection only when they run automatically, against pushed commits, with recorded history. Requirements:
1. Every `evidenceRuns` entry must record the pushed commit SHA it ran against. A run against uncommitted working-tree state is a development note, not promotion evidence; it must be re-run and re-recorded once the owning PR's branch is pushed. All evidence runs currently in the manifest predate the split and must be refreshed this way.
2. Add one aggregated non-blocking `reliability-gates` CI job that runs `check:reliability-gates` plus every command-backed gate command, and uploads a machine-readable per-gate result artifact (gate id, command, result, duration, commit SHA, runner platform). Run it nightly and on PRs that touch terminal/session/provider/startup paths. Because every gate command except the live Playwright slice is a focused vitest run measured in seconds (a combined run of all 28 unit-layer gate test files completed in 10.2s with 928 tests on 2026-07-02), the job's cost is dominated by setup, not by gate count.
3. Promotion to `active`/blocking requires history from that CI job — the 100-consecutive-run or 14-day criteria in Promotion Criteria — not local runs. Flakes recorded by the job must be triaged before the affected gate can be promoted, and an unexplained flake on an `active` gate is the demotion signal.
4. Checker follow-up: once the CI artifact exists, extend the checker so `active` gates require at least one CI-provenance evidence run, and so evidence-run commit SHAs are verified to be ancestors of the branch under check.
5. Rebase audit: before any merge claim, mechanically verify every declared `testFiles` entry exists on fresh `origin/main` (or on the rebased tree) rather than trusting memory. The #6949 WebGL rename is the standing example of why.
## Milestone 2
After Milestone 1 works, add the next three gates as experimental entries with deterministic harnesses:
- `terminal-geometry.visible-convergence`
- `xterm-addon.boundary-containment`
- `startup-upgrade.persisted-session-corpus`
These should not become blocking until the team has red/green, runtime, and flake evidence. The goal is fewer useful gates, not a larger noisy suite.
## Milestone 3
Milestone 3 is where the plan stops being only a lower-layer regression net and starts exercising the real terminal runtime:
- Register existing terminal perf and E2E tests as experimental manifest gates only after reviewing their oracles, waits, artifacts, and flake history.
- Add the live local PTY gate with a tight oracle: spawn a shell, print a unique token, switch hidden-visible, assert the same PTY/session id, verify visible buffer content, type again, and prove no duplicate tab or blank pane appeared.
- Add Windows ConPTY coverage on Windows CI for echo, resize/readback, cursor visibility, CJK/emoji output, keyboard reset, shell resolution parity, and activation after restore.
- Add daemon degraded/startup reconcile contracts before relying on daemon fallback behavior.
- Add live SSH, WSL, remote-runtime mirror polling, and provider-listing edge contracts before treating remote/SSH restore as broadly covered; the current mocked SSH deferred-reattach and expired-relay tests are useful lower-layer proof, not live remote coverage.
- Add perf gates with explicit budgets for input latency, event-loop delay, hidden-output pressure, daemon stream backpressure, no hot `listSessions`, pending bytes, renderer ACK behavior, store hot paths, and runtime/mobile stream byte caps.
- Add IME/synthetic-input coverage at the lowest deterministic layer, with platform-specific manual or soak coverage only where real IMEs are required.
Milestone 3 gates should begin as `experimental`. They become `soak` only when the command is stable enough to run repeatedly and the failure artifact tells an engineer exactly what broke. They become `blocking` only after the promotion criteria above are satisfied.
@@ -0,0 +1,712 @@
# Reliability Pain Points and Improvement Plan
Date: 2026-06-30
## Scope
This note summarizes the recent reliability review across:
- Recently merged reliability PRs, especially terminal/tab/startup/session work from 2026-06-26 through 2026-06-30.
- GitHub issues labeled `P0` and `P1`.
- Local git history for authorship when issue text or PR bodies named a regressing change.
The goal is to identify where Orca is hurting, what engineering improvements would reduce repeat incidents, and what can fairly be said about attribution.
## Issue Snapshot
At the time of review:
- Open `P0`: 9
- Total `P0`: 14
- Open `P1`: 191
- Open `P1` split: 90 bugs, 86 enhancements, 15 other
Last seven days reviewed: 2026-06-23 through 2026-06-30.
- Merged PRs in the window: 386
- `P0` issues created in the window: 8
- `P1` issues created in the window: 83
The last-seven-day issue set is the strongest signal for the current pain. It includes the new-tab autoclose `P0`, Windows terminal activation hangs, renderer CPU/input lag, Windows startup visibility failure, Codex config corruption on Windows, a large terminal/IME/rendering cluster, WSL/remote/provider drift, and agent/sidebar/session identity drift.
Open `P0` issues:
- [#6877](https://github.com/stablyai/orca/issues/6877): macOS 1.4.106 nested `simcam` dylib is unnotarized and rejected by Gatekeeper.
- [#6874](https://github.com/stablyai/orca/issues/6874): Windows `AppHangB1` when opening or activating a terminal session; runtime stuck in `stale_bootstrap`.
- [#6873](https://github.com/stablyai/orca/issues/6873): likely duplicate or near-duplicate of #6874, though the issue metadata says macOS while the title/body describe Windows.
- [#6795](https://github.com/stablyai/orca/issues/6795): app became very slow after an update; typing can lag by 2-5 seconds.
- [#6773](https://github.com/stablyai/orca/issues/6773): cannot create a new terminal tab or agent; the tab opens and closes immediately.
- [#6655](https://github.com/stablyai/orca/issues/6655): high `Orca Helper (Renderer)` CPU on Mac Mini M4 causing severe UI/input lag.
- [#5787](https://github.com/stablyai/orca/issues/5787): whole Windows window freezes; input dies in every pane; sessions close on click.
- [#5356](https://github.com/stablyai/orca/issues/5356): session loss on first launch after upgrading from 1.4.65 to 1.4.68.
- [#5314](https://github.com/stablyai/orca/issues/5314): Windows close button and Alt+F4 do nothing.
Closed `P0` issues worth keeping in the reliability history:
- [#6233](https://github.com/stablyai/orca/issues/6233): Windows window fails to appear while the process runs in the background.
- [#6163](https://github.com/stablyai/orca/issues/6163): Orca-managed Codex `config.toml` duplicate key.
- [#5377](https://github.com/stablyai/orca/issues/5377): native watcher main-process crash on macOS.
- [#5144](https://github.com/stablyai/orca/issues/5144): Windows close-confirmation deadlock.
- [#5109](https://github.com/stablyai/orca/issues/5109): sidebar agent status mismatch.
## P1 Reliability Clusters
The `P1` set is too large to treat as a flat list. The high-signal reliability clusters are:
### Terminal Rendering and Input
- [#6901](https://github.com/stablyai/orca/issues/6901): garbled text.
- [#6764](https://github.com/stablyai/orca/issues/6764): Windows terminal issues.
- [#6632](https://github.com/stablyai/orca/issues/6632): display artifacts in Claude Code full-screen TUI.
- [#6144](https://github.com/stablyai/orca/issues/6144): laggy Claude Code terminal scroll.
- [#5969](https://github.com/stablyai/orca/issues/5969): remote-over-SSH Codex display artifacts.
- [#5345](https://github.com/stablyai/orca/issues/5345): Windows/WSL scrolling causes duplicated or garbled text and content overlap.
### IME and International Text
- [#6905](https://github.com/stablyai/orca/issues/6905): Vietnamese IME input broken in terminal.
- [#6765](https://github.com/stablyai/orca/issues/6765): Linux Sogou Pinyin/fcitx only commits the first CJK character.
- [#6698](https://github.com/stablyai/orca/issues/6698): Vietnamese Telex input loses characters.
- [#5921](https://github.com/stablyai/orca/issues/5921): CJK characters duplicated in Orca terminal on Windows.
- [#5262](https://github.com/stablyai/orca/issues/5262): Arabic text rendering appears reversed or disconnected in terminal input.
### Paste and Clipboard
- [#5365](https://github.com/stablyai/orca/issues/5365): terminal freezes for seconds when pasting long text on Windows.
- [#5358](https://github.com/stablyai/orca/issues/5358): Windows paste preview collapses long text and cannot expand it.
- [#5919](https://github.com/stablyai/orca/issues/5919): generic paste reliability issue.
- [#5960](https://github.com/stablyai/orca/issues/5960): bad screen result when pasting links.
- [#6364](https://github.com/stablyai/orca/issues/6364): pasting images into an agent CLI does not work using Remote Host.
### SSH, Remote Runtime, WSL, and Provider Boundaries
- [#6106](https://github.com/stablyai/orca/issues/6106): SSH terminal loses pre-TUI shell output after Codex tab restore.
- [#6846](https://github.com/stablyai/orca/issues/6846): remote PTY sessions do not stay alive on disconnect.
- [#6908](https://github.com/stablyai/orca/issues/6908): WSL project added through UI does not discover branches/base refs, while CLI repo add works.
- [#6907](https://github.com/stablyai/orca/issues/6907): Codex launched in a WSL worktree does not appear in agent status/sidebar.
- [#6916](https://github.com/stablyai/orca/issues/6916): OMP launched in a WSL worktree has no live agent status.
- [#6032](https://github.com/stablyai/orca/issues/6032): Remote Orca Servers can lose host-scoped repo and project state.
- [#6688](https://github.com/stablyai/orca/issues/6688): viewing a diff on remote host errors.
- [#6753](https://github.com/stablyai/orca/issues/6753): remote-host GitHub merge errors.
### Agent, Tab, Session, and Sidebar State Drift
- [#6910](https://github.com/stablyai/orca/issues/6910): active agent tabs duplicate UI sessions when switching repositories.
- [#6803](https://github.com/stablyai/orca/issues/6803): multiple working agents thrash their order in the sidebar.
- [#6072](https://github.com/stablyai/orca/issues/6072): mobile keeps showing old agent rows after terminals are closed.
- [#5913](https://github.com/stablyai/orca/issues/5913): Pi compact agent row remains after closing its terminal tab.
- [#5718](https://github.com/stablyai/orca/issues/5718): OMP sessions not showing up in Agent Session History or filter options.
- [#5404](https://github.com/stablyai/orca/issues/5404): false "agent running" notifications persist after starting Claude Agents on VSSH/WSL.
### Mobile Terminal and Tabs
- [#5421](https://github.com/stablyai/orca/issues/5421): iOS workspace tabs do not open.
- [#6756](https://github.com/stablyai/orca/issues/6756): iOS terminal renders in cursive/italic font and continuously flickers.
- [#5628](https://github.com/stablyai/orca/issues/5628): mobile resizing feature request, closely related to terminal fit/reflow reliability.
### Browser, Profile, and Embedded Runtime
- [#6923](https://github.com/stablyai/orca/issues/6923): browser profiles do not isolate storage.
- [#6760](https://github.com/stablyai/orca/issues/6760): floating browser cannot load any page.
- [#6268](https://github.com/stablyai/orca/issues/6268): embedded-browser cookie/session limitation prevents Auth0 login.
- [#6875](https://github.com/stablyai/orca/issues/6875): cannot copy Google Chrome cookies database.
- [#6652](https://github.com/stablyai/orca/issues/6652): browser zoom broken.
### Startup, Install, and Platform Hangs
- [#5657](https://github.com/stablyai/orca/issues/5657): macOS startup PATH probe can hang under Endpoint Security agents.
- [#5107](https://github.com/stablyai/orca/issues/5107): Orca opens very slowly on Windows 11 and leaves a residual process.
- [#5989](https://github.com/stablyai/orca/issues/5989): Windows Defender blocks application during installation.
## Recent Reliability PR Context
The late-June reliability work clustered around the same few subsystems:
- New terminal tabs opening and immediately closing: [#6796](https://github.com/stablyai/orca/pull/6796), [#6801](https://github.com/stablyai/orca/pull/6801).
- Stale/frozen/blank terminals: [#6514](https://github.com/stablyai/orca/pull/6514), [#6800](https://github.com/stablyai/orca/pull/6800), [#6833](https://github.com/stablyai/orca/pull/6833), [#6830](https://github.com/stablyai/orca/pull/6830), [#6866](https://github.com/stablyai/orca/pull/6866).
- PTY size and layout desync: [#6644](https://github.com/stablyai/orca/pull/6644), [#6649](https://github.com/stablyai/orca/pull/6649), [#6684](https://github.com/stablyai/orca/pull/6684), [#6725](https://github.com/stablyai/orca/pull/6725), [#6785](https://github.com/stablyai/orca/pull/6785), [#6853](https://github.com/stablyai/orca/pull/6853).
- Renderer/xterm crash hardening: [#6852](https://github.com/stablyai/orca/pull/6852), [#6872](https://github.com/stablyai/orca/pull/6872), [#6855](https://github.com/stablyai/orca/pull/6855), [#6868](https://github.com/stablyai/orca/pull/6868), [#6856](https://github.com/stablyai/orca/pull/6856), [#6857](https://github.com/stablyai/orca/pull/6857).
- Windows shell/spawn/input: [#6537](https://github.com/stablyai/orca/pull/6537), [#6876](https://github.com/stablyai/orca/pull/6876), [#6858](https://github.com/stablyai/orca/pull/6858), [#6890](https://github.com/stablyai/orca/pull/6890).
- Hidden/agent startup behavior: [#6824](https://github.com/stablyai/orca/pull/6824), [#6798](https://github.com/stablyai/orca/pull/6798), [#6836](https://github.com/stablyai/orca/pull/6836).
## Last-Seven-Day Regression Origin Map
This section maps recent regressions to the place where the system failed, and to the gate that should catch the same class next time.
| Issue | Symptom | Regression point | Fix / related PR | Missing gate |
| --- | --- | --- | --- | --- |
| [#6773](https://github.com/stablyai/orca/issues/6773) | New terminal/agent tab opens and closes immediately | Dead-session reconciliation could treat a fresh PTY binding as absent from a stale `listSessions()` snapshot. This likely traces to the local dead-session reconcile path introduced for hidden exited panes in [#6514](https://github.com/stablyai/orca/pull/6514). | [#6796](https://github.com/stablyai/orca/pull/6796), [#6801](https://github.com/stablyai/orca/pull/6801) | Newborn terminal lifecycle test: create shell and agent tabs while liveness reconciliation is in flight; assert the tab cannot be closed by an older snapshot. |
| [#5356](https://github.com/stablyai/orca/issues/5356) | First launch after upgrade loses active floating/agent terminal sessions | Cold-restore relied on state that only versions after the fix could write, so users upgrading from pre-fix versions had no migration source. Issue text explicitly names [#5234](https://github.com/stablyai/orca/pull/5234) and [#5240](https://github.com/stablyai/orca/pull/5240). | Not fully closed in current open `P0` set | Upgrade fixture test: boot current app with persisted state from the last affected production version and assert active terminal/agent sessions are preserved or visibly recoverable. |
| [#5319](https://github.com/stablyai/orca/issues/5319) | Linux/Wayland terminals render but stop accepting keyboard/scroll input | Wayland-aware GPU safeguard was removed in [#1344](https://github.com/stablyai/orca/pull/1344), leaving eager GPU channel setup able to wedge terminal input on affected systems. | [#6557](https://github.com/stablyai/orca/pull/6557) | Platform GPU matrix test: Linux Wayland launch with terminal WebGL/input smoke and assertion that GPU flags preserve input, not only rendering. |
| [#6233](https://github.com/stablyai/orca/issues/6233) | Windows process runs but no window appears | Startup window reveal depended on Electron `ready-to-show`; if that event stalled, there was no fallback reveal or user-visible failure. | [#6462](https://github.com/stablyai/orca/pull/6462) | Startup watchdog test: simulate missing `ready-to-show` on Windows and assert the app either reveals or exits with a clear recovery path. |
| [#6163](https://github.com/stablyai/orca/issues/6163) | Orca-managed Codex `config.toml` fails with duplicate `hooks.state` keys on Windows | Two TOML writers serialized the same Windows path key differently, and Orca deduped textually rather than by decoded key. | [#6318](https://github.com/stablyai/orca/pull/6318), follow-up [#6388](https://github.com/stablyai/orca/pull/6388) bug-scan fix | Cross-writer config round-trip test: seed config with Codex-written literal Windows paths, run Orca hook sync twice, then parse with a TOML parser and assert one decoded key. |
| [#6244](https://github.com/stablyai/orca/issues/6244) | Headless-launched automation terminal showed only a bash prompt until unmount/remount | Hidden/background automation worktrees were not mounted early enough for output/replay to hydrate on first view. | [#6568](https://github.com/stablyai/orca/pull/6568) | Headless automation mount test: launch background automation, wait for output, first-focus the tab, and assert output is already visible. |
| [#6331](https://github.com/stablyai/orca/issues/6331) | WSL workspaces with UNC cwd hit `DaemonProtocolError` | Windows/WSL path handling crossed provider boundaries and daemon spawn assumptions. | [#6536](https://github.com/stablyai/orca/pull/6536) | WSL UNC provider contract: add/open/delete workspace whose root is `\\wsl.localhost\...`; assert daemon and file operations use the right host/path semantics. |
| [#6336](https://github.com/stablyai/orca/issues/6336) | Monorepo subfolder import opened the repo root instead of the selected subfolder | Git-root normalization leaked into the first-terminal cwd/user-facing workspace root behavior. | [#6574](https://github.com/stablyai/orca/pull/6574) | Add-project matrix: repo root, monorepo subfolder, folder workspace, remote runtime; assert file tree root and first terminal cwd separately. |
| [#6147](https://github.com/stablyai/orca/issues/6147) | Chinese full-width punctuation became half-width ASCII in terminal | IME/composition forwarding lacked coverage for macOS full-width punctuation and composition-owned text. | [#6417](https://github.com/stablyai/orca/pull/6417) | IME matrix test: macOS CJK full-width punctuation, Vietnamese Telex, Linux fcitx/Sogou, Windows CJK; assert committed bytes and displayed cells. |
| [#5656](https://github.com/stablyai/orca/issues/5656) / [#5653](https://github.com/stablyai/orca/issues/5653) | Windows Claude Code prompt showed phantom/overwritten characters until resize | Rewrite-style terminal output did not reliably force visible row repaint for CR/CHA/erase updates. | [#6544](https://github.com/stablyai/orca/pull/6544), [#6449](https://github.com/stablyai/orca/pull/6449) | Terminal redraw fixture: feed CR, CHA, backspace, erase-line/screen and assert visible cells after each chunk without resize. |
| [#5161](https://github.com/stablyai/orca/issues/5161) | PowerShell terminal failed to spawn with Windows error code 5 | Windows shell resolver handed ConPTY a bare `pwsh.exe`, which could resolve to an App Execution Alias stub. | [#6537](https://github.com/stablyai/orca/pull/6537), [#6876](https://github.com/stablyai/orca/pull/6876) | Windows shell resolution contract: auto and explicit PowerShell selections must resolve to real executables and skip WindowsApps aliases. |
| [#6270](https://github.com/stablyai/orca/issues/6270) | Restoring agent sessions was not working for SSH/runtime use cases | Resume logic and runtime host restrictions did not cover SSH workspaces. | [#6685](https://github.com/stablyai/orca/pull/6685) | Agent resume provider matrix: local, daemon, SSH, remote runtime, Windows shell; assert resume command is queued on the configured host/provider. |
| [#6852](https://github.com/stablyai/orca/pull/6852) / [#6872](https://github.com/stablyai/orca/pull/6872) | Terminal search could crash terminal surface on narrow viewports | xterm search decorations received invalid dimensions and threw through the terminal surface. | [#6852](https://github.com/stablyai/orca/pull/6852), [#6872](https://github.com/stablyai/orca/pull/6872) | xterm addon safety test: narrow viewport, zero/near-zero columns, wrapped matches, keyboard next/previous; assert addon errors are caught and pane remains usable. |
| [#6855](https://github.com/stablyai/orca/pull/6855) | xterm web-link provider `RangeError` could kill the Windows renderer | Link provider ran synchronously inside xterm with no guard around pathological wrapped lines. | [#6855](https://github.com/stablyai/orca/pull/6855) | Addon boundary policy: all xterm addon callbacks are guarded and failure disables only that addon for that pane. |
| [#6853](https://github.com/stablyai/orca/pull/6853) | Split-right PTY stayed at `0x0` and rendered a white screen | Deferred spawn measured an unlaid-out pane; post-spawn reconcile could terminate before forwarding a usable size. | [#6853](https://github.com/stablyai/orca/pull/6853) | Split spawn invariant: visible panes must never keep PTY size `0x0`; fallback size must be forwarded if measurement is unavailable. |
The repeated pattern is not one person repeatedly making the same mistake. It is that Orca does not yet have enough executable contracts around lifecycle, provider boundaries, terminal sizing, xterm safety, platform shell behavior, and upgrade state.
## PR Process Evidence
Reading the PR descriptions for the confirmed or likely regression chains shows that the failure was usually not "no process." The failure was that the process accepted plausible local validation without requiring the invariant that would have protected the escaped user path.
### #6514: Dead-Session Reconcile Introduced a Newborn-Tab Hazard
[PR #6514](https://github.com/stablyai/orca/pull/6514) was a Brennan-authored reliability fix for frozen terminal panes after a backgrounded agent exits. The PR body shows a substantial process: design review, completeness verification, headline behavior status, perf audit, code-review loop, Electron validation, and targeted unit tests. The stated non-goal explicitly excluded SSH/remote reconciliation, and the implementation revalidated pane/PTY identity at apply time.
What still got through:
- The PR protected "stale dead pane should close" but did not name the dual invariant "a fresh or newly bound pane cannot be closed by a liveness snapshot requested before that binding existed."
- The tests covered live/dead/remote/idempotency paths, but not the TOCTOU case where `listSessions()` is requested, a new PTY binds before the response returns, and the stale response omits the newborn id.
- Reviewers accepted "healthy panes are untouched" as a semantic claim, but the executable gate did not prove that claim under delayed session enumeration.
Skill implication:
- `brennan-yolo-lite`, `auto-design-review-fix`, `review-code`, and `brennan-test-changes` all need to force lifecycle changes to state the inverse safety property, not only the intended cleanup behavior.
- For terminal lifecycle changes, the review checklist should ask "what newer state can this async observation accidentally destroy?"
### #5234 and #5240: Session-Restore Fixes Missed Old-Version Upgrade Fixtures
[PR #5234](https://github.com/stablyai/orca/pull/5234) and [PR #5240](https://github.com/stablyai/orca/pull/5240) were Jinwoo-authored fixes for slow-startup daemon PTY restoration and quit-time agent session persistence. Their PR descriptions show AI review, negative controls, targeted tests, and high-quality e2e coverage against the newly modeled behavior.
What still got through:
- The tests proved the new behavior once the new data and startup gates existed.
- They did not appear to boot the current app against serialized persisted state from the affected production versions before the fix.
- The escaped class is an upgrade/migration class: a fix can be correct for state written after the fix while still losing users who upgrade from state written before it.
Skill implication:
- Startup, restore, daemon, and persistence PRs need an "old state corpus" gate: seed data from the last affected production version, not only state produced by current code.
- Negative controls should include "old production fixture fails before fix and is preserved after fix" when the bug is upgrade-related.
### #1344: GPU Rendering Parity Missed Linux Wayland Input Failure
[PR #1344](https://github.com/stablyai/orca/pull/1344) replaced global GPU opt-ins with VS Code-style GPU startup flags and terminal GPU acceleration settings. The PR description cites reference behavior and local tests, but it does not show the later, stricter Brennan PR process markers used in the June reliability fixes.
What still got through:
- The change removed or bypassed the Wayland-aware GPU safeguard later identified by [PR #6557](https://github.com/stablyai/orca/pull/6557).
- Validation covered configuration and pane lifecycle tests, but not a Linux Wayland terminal input smoke where rendering appears live while keyboard/scroll input is wedged.
- One TypeScript command was reported as failing on existing project-include issues, which made the PR less clean as a release-risk artifact.
Skill implication:
- Cross-platform rendering/startup changes need an explicit platform matrix tied to the failure mode: not just "renders," but "renders and accepts input."
- Reference implementations are useful context, but factory review must still require Orca-specific platform hazards to be named and tested or accepted.
### Regression Lessons From Fresh Review
Fresh reviewer passes on the recent regression chains pointed to these reusable misses. These are class-level gates, not new blame findings.
- [#6514](https://github.com/stablyai/orca/pull/6514) -> [#6773](https://github.com/stablyai/orca/issues/6773), fixed by [#6796](https://github.com/stablyai/orca/pull/6796) and [#6801](https://github.com/stablyai/orca/pull/6801): the original PR correctly proved the forward cleanup case, where a hidden local/daemon PTY exits and should reconcile on resume while live listed panes survive. The missed invariant was the inverse lifecycle property: a destructive liveness snapshot can only close state that existed before the snapshot was requested. The escaped path was a macOS local new shell or agent tab where `listSessions()` was requested, a new PTY bound before the response returned, the stale response omitted the newborn id, and the renderer routed the healthy binding through exit teardown. The next gate is a Terminal Snapshot Freshness and Ownership Gate: a fake daemon holds `listSessions()` at `t0`, binds a newborn PTY at `t1`, resolves the stale `t0` snapshot without that PTY, and asserts the tab/pane survives, still owns the same PTY, accepts input, and renders output. A later post-bind snapshot may close a genuinely dead PTY, but only when provider, pane, PTY id, and binding epoch still match.
- [#6800](https://github.com/stablyai/orca/pull/6800), with [#5240](https://github.com/stablyai/orca/pull/5240), [#6411](https://github.com/stablyai/orca/pull/6411), [#6514](https://github.com/stablyai/orca/pull/6514), and [#6833](https://github.com/stablyai/orca/pull/6833) as related context: the original fix correctly proved that active worktree-sleep records should not be claimed merely because old tab/layout state exists; visible connecting panes with restorable identity can own wake, stale hidden panes fresh-resume, and completed hibernation evidence stays passive. The missed invariant was provider-session ownership, not "pane connects now." For each provider-session claim key, exactly one durable owner must exist: a live hook/status for that same provider session, a queued/sent resume command for that same provider session, or an explicit retained record because spawn/connect failed. Layout match, PTY wake hints, replayed terminal bytes, and cold-restored screen snapshots are display evidence, not ownership evidence. The next gate is an Agent Provider Session Ownership Matrix covering active focused tabs, inactive tabs, visible non-focused splits, live/quit/worktree-sleep origins, replay-only/cold-restore-only/wrong-session/no-hook/delayed-hook cases, and repeated workspace activation.
- [#5234](https://github.com/stablyai/orca/pull/5234) / [#5240](https://github.com/stablyai/orca/pull/5240) -> [#5356](https://github.com/stablyai/orca/issues/5356): the original PRs correctly proved slow-daemon restoration and newly written quit-origin provider-session persistence. The missed invariant was first-open-after-upgrade compatibility: startup/restore fixes must preserve or explicitly recover user sessions from the last affected production persisted-state schema, not only from state written by the fixed build. The escaped path was first launch after a 1.4.65 -> 1.4.68 upgrade, including Windows 10 active floating shell and Claude panes, where the old version had no `sleepingAgentSessionsByPaneKey` payload to consume. The next gate is a Persisted Session Upgrade/Restore Contract: boot current Orca against immutable user-data fixtures from the last affected version, including active and inactive tabs, visible and hidden splits, old tab-level PTY wake hints, missing `sleepingAgentSessionsByPaneKey`, live/quit/worktree-sleep/legacy origins, repeat activation, and a second restart after current code writes upgraded state. The oracle rejects blank replacement panes, duplicate resume tabs, silent session loss, and "works only after current code writes new state."
- [#1344](https://github.com/stablyai/orca/pull/1344) -> [#5319](https://github.com/stablyai/orca/issues/5319) / [#6557](https://github.com/stablyai/orca/pull/6557): the original PR correctly attempted to replace broad GPU opt-ins with VS Code-style GPU channel flags and prove terminal GPU/WebGL fallback at unit level. The missed invariant was terminal platform liveness, not visual readiness. A terminal is healthy only if it renders output, receives focus, delivers keyboard input to the correct PTY, echoes process output back to the xterm buffer, scrolls, and survives GPU/renderer startup on the target platform. The next gate is a Terminal Platform Liveness Gate across Linux Wayland GPU paths, headless Weston plus at least one scheduled/manual headful Wayland run, control Linux/macOS/Windows paths when renderer policy is shared, active and inactive tabs, visible and hidden panes, first mount, tab switch, repeated workspace activation, local PTY, and SSH/remote PTY. Screenshots alone never pass.
- [#6644](https://github.com/stablyai/orca/pull/6644) / [#6649](https://github.com/stablyai/orca/pull/6649) / [#6725](https://github.com/stablyai/orca/pull/6725) / [#6785](https://github.com/stablyai/orca/pull/6785) / [#6794](https://github.com/stablyai/orca/pull/6794) / [#6853](https://github.com/stablyai/orca/pull/6853) / [#6939](https://github.com/stablyai/orca/pull/6939): the fixes correctly proved pieces of the PTY geometry class: first-mount column desync, single-frame insufficiency, split-mount convergence, requested-size versus applied-size drift, mobile-owned PTY exceptions, visible split `0x0` fallback, and visible geometry reassertion after stable fit. The missed invariant was cross-layer terminal geometry authority. A visible desktop-owned terminal is not valid until xterm `cols/rows`, fit/proposed dimensions, applied PTY winsize, shell-visible `stty`/`stdout.columns`, and echo-wrap behavior converge after layout settles. Hidden/inactive panes may defer resize, and mobile/remote providers may intentionally diverge, but every transition into visible desktop ownership must converge. The next gate is a terminal geometry matrix with fault injection for first `0x0` fit, delayed layout settle, dropped/delayed resize acknowledgement, requested-size-versus-applied-size drift, SSH-style unknown applied size, hidden-to-visible activation, split create/collapse, restore, repeated workspace activation, and no redundant hidden SIGWINCH.
- [#6852](https://github.com/stablyai/orca/pull/6852) / [#6872](https://github.com/stablyai/orca/pull/6872) / [#6855](https://github.com/stablyai/orca/pull/6855), with [#6868](https://github.com/stablyai/orca/pull/6868), [#6856](https://github.com/stablyai/orca/pull/6856), and [#6857](https://github.com/stablyai/orca/pull/6857) as nearby WebGL/renderer-risk hardening: the fixes correctly moved toward safe wrappers for search dimensions, keyboard search navigation, link providers, WebGL addon patches, and renderer/GPU crash fallback. The missed invariant was xterm addon boundary containment. Optional addons are untrusted boundary code: search decorations, link providers, WebGL attach/reset/dispose, renderer fallback, hover callbacks, and stale async callbacks must not throw across the pane, React, or window boundary. The next gate is an xterm-addon-boundary harness that injects throwing addon behavior under active tabs, inactive tabs, visible and hidden splits, hidden-to-visible resume, floating tab switch, repeated workspace activation, and pane destroy/recreate; the oracle is pane-scoped addon disable/degrade with one breadcrumb, no React/window crash, and surviving focus, input, PTY echo, and rendering.
Concrete gate shapes from the fresh passes:
- **Snapshot freshness harness**: defer and reorder `listSessions()` / `listTerminals()` responses. Request a snapshot at `t0`, bind a new PTY at `t1`, resolve the `t0` response without the new PTY, and assert the pane survives with PTY id, tab ownership, and input/output proof. Then request a fresh `t2 > t1` snapshot for a genuinely killed PTY and assert teardown works. Run active/inactive tabs, visible/hidden splits, first-input probes, restore/reattach, repeat activation, and SSH/remote unknown-liveness variants.
- **Provider-session ownership matrix**: model provider-session ownership separately from terminal render output. For live, quit, active worktree-sleep, and completed worktree-sleep origins, prove each provider session is exactly one of owned, resuming, or intentionally retained. Cover active focused tabs, inactive hidden tabs, visible non-focused splits, delayed/missing/wrong hooks, duplicate legacy records, fresh-spawn failure, and repeat activation. Displayed replay must never count as ownership.
- **Old-version restore corpus**: keep immutable user-data fixtures from the last affected production versions, especially pre-#5232 session state. Boot current Orca against those fixtures and assert every persisted PTY/agent record is preserved, resumed once, warm-reattached once, or visibly marked unrecoverable. Include second restart after current Orca writes upgraded state.
- **Wayland platform liveness**: extend the Linux Wayland verifier so it proves terminal render, focus, keyboard input, PTY echo, scrollback movement, and GPU crash/stall absence across active/inactive tabs, visible/hidden splits, first mount, tab switch, restore, repeated activation, local PTY, and SSH/remote runtime. Screenshot-only evidence is not enough.
- **Terminal geometry convergence**: add an `assertTerminalGeometryConverged` oracle that compares xterm cols/rows, fit/proposed dimensions, applied PTY size when available, shell-visible `stty size` / `$COLUMNS`, and deterministic echo/wrap behavior. Inject dropped resize, delayed resize until visible, stale applied size, null SSH-style readback, initial `0x0`, and layout settling after more frames than old fixed budgets.
- **xterm addon boundary harness**: inject throwing search decorations, link providers, WebGL attach/reset/dispose, and stale async callbacks under active tab, inactive tab, visible split, hidden split, hidden-to-visible resume, floating tab switch, repeat activation, and pane destroy/recreate. Assert no `window.onerror` or React boundary hit, one structured breadcrumb, addon-scoped degradation, focus/input survival, PTY/output rendering, and WebGL fallback to DOM when needed.
### Corrective PRs Show the Desired Direction
The later fixes, especially [#6801](https://github.com/stablyai/orca/pull/6801), show the right shape: root-cause description, happens-before proof, explicit `auto-design-review-fix`, completeness verification, code-review loop, `brennan-test-changes`, and targeted tests for the stale snapshot/newborn binding race. [#6852](https://github.com/stablyai/orca/pull/6852), [#6853](https://github.com/stablyai/orca/pull/6853), and [#6855](https://github.com/stablyai/orca/pull/6855) also show good local invariant tests around xterm addon throws and PTY `0x0` sizing.
The process improvement is to make that shape mandatory before the regression escapes, and to promote only the deterministic, red/green parts into blocking CI.
## Core Pain Points
### 1. Terminal Lifecycle Is Inferred Instead of Owned
Many paths infer terminal state from a combination of pane visibility, provider session membership, stored pane layout, active leaf ids, sleeping-agent records, and xterm mount state. That makes it easy for a stale observation to tear down a fresh pane, or for a dead pane to look live.
Common symptoms:
- New tab opens and immediately closes.
- Hidden terminal exits but the visible pane later looks frozen and swallows input.
- Sleeping or hibernated agents get stranded in stale panes.
- Mobile and desktop disagree about which terminal/session still exists.
### 2. PTY Size Authority Is Split Across Too Many Places
`FitAddon`, `ResizeObserver`, deferred `requestAnimationFrame`, mobile-fit locks, hidden-pane guards, PTY locks, split layout equalization, and provider resize forwarding all compete to decide the active terminal size.
Common symptoms:
- PTY born at `0x0`.
- White split pane.
- First-mount column desync.
- TUI output wraps at the wrong width until manual resize.
- Mobile-size terminals fight desktop-size restoration.
### 3. xterm Addons Are Unsafe Boundary Code
The terminal surface trusts addon behavior too much. Recent failures came from search decorations, web-link providers, WebGL texture atlas resets, CJK/wide glyph rendering, and synchronized-output buffering.
Any addon that can throw synchronously or produce invalid dimensions can take down the workbench unless Orca wraps it defensively.
### 4. Provider Semantics Drift
Local PTY, daemon PTY, SSH relay, remote runtime, WSL, Windows ConPTY, Linux Wayland, and mobile sessions do not share the same truth source for liveness, size, replay, cwd, shell, or reconnect behavior.
Common symptoms:
- Local-only liveness checks are accidentally applied to SSH/remote panes.
- Remote paths miss fixes made only in the renderer.
- WSL works through CLI add but not UI add.
- Daemon can be protocol-healthy while PTY spawn is degraded.
### 5. Agent Identity Is Coupled to Terminal UI Details
Agent status, sidebar rows, compact rows, mobile session tabs, sleeping-agent records, terminal helper identity, and pane reuse all share responsibility for "what agent is this?"
Common symptoms:
- Duplicate agent tabs.
- Stale sidebar rows after terminal close.
- Wrong agent icon/title.
- False running/completed notifications.
- Worktree switching changes agent ordering or ownership.
Class-level invariant:
- Agent/provider session replay must be ownership-safe. When code touches agent launch, restore, sleep, hibernate, dedupe, clearing, or workspace activation, it must prove Orca does not replay or resume a provider session id that is already owned, queued, pending, or live in that workspace. This applies across active and inactive tabs, visible and hidden panes, repeat activation of the same workspace, and session records whose origin is live, quit, or worktree sleep.
- Motivating example: the #6800 escape showed how code can conflate "this pane will connect right now" with "this pane/session owns this provider session." A valid inactive or hidden existing session can then look unowned, causing workspace activation to replay `agent resume <same-session-id>` instead of recognizing the session is already represented. The gate should protect the whole class, not only the #6800 path.
- Display evidence is not ownership evidence. Replayed terminal bytes, cold-restored screen snapshots, tab layout matches, and "will connect soon" state must never count as proof that a provider session id is already claimed.
### 6. Upgrade and Startup Are Under-Tested as First-Class Flows
Several failures only appear after an app update, daemon survival across versions, old persisted state, stale helper paths, or a first launch after migration.
Common symptoms:
- First launch after upgrade loses sessions.
- Daemon remains reachable but cannot spawn PTYs.
- App process runs but no window appears.
- Release packaging/notarization works at top level but fails for a nested binary.
### 7. Reliability Fixes Can Regress Performance or Crash Safety
Several reliability fixes add watchers, reconciliation loops, terminal replay, git scans, or richer diagnostics. Those are high-risk performance surfaces if they wake hidden panes, serialize large state, multiply subprocesses, or make cleanup scans wait for final results before showing progress.
Common symptoms:
- Terminal input becomes correct but slower under foreground TUI redraw or background PTY output.
- Hidden panes keep waking the renderer for data/title/spinner frames.
- Store subscribers or persistence writes serialize large terminal/worktree maps on common interactions.
- Git/worktree cleanup opens a modal that spins, duplicates rows, hangs on a subprocess, or crashes on Windows/WSL/SSH paths.
- Safety checks run unbounded git subprocesses or trust stale cleanup evidence before destructive removal.
## Proposed Improvements
### A. Build a Terminal Lifecycle State Machine
Create a single source of truth for terminal lifecycle:
- `newborn`
- `connecting`
- `live`
- `hidden-live`
- `exited-observed`
- `exited-unobserved`
- `hibernated`
- `restoring`
- `degraded`
- `closed`
All teardown, restore, reconnect, hibernation, and liveness decisions should transition through this machine. Avoid having individual React effects independently infer lifecycle from partial state.
### B. Make Liveness Reconciliation Epoch-Based
Every pane/PTY binding should carry a monotonic renderer-local epoch or generation. Any async liveness snapshot must include the epoch it was requested against.
Rules:
- A snapshot requested before a bind cannot close that bind.
- A provider membership result can only prove death when the current binding still exactly matches the request context: pane id, provider id, connection id, PTY id, and epoch.
- Remote/SSH/WSL providers must supply provider-scoped liveness, or the reconciler must treat them as unknown.
This generalizes the timestamp guard added for newborn terminal tabs and makes it harder to reintroduce the class.
### C. Define a PTY Size Ownership Protocol
Document and enforce one protocol for size authority:
- Initial spawn size source.
- Hidden-pane spawn behavior.
- Mobile-fit lock behavior.
- Split layout settle behavior.
- Fallback size behavior.
- Provider resize acknowledgement.
- Applied PTY size reporting.
- Dropped, delayed, or skipped resize recovery.
- Hand-off from reconcile loop to live `ResizeObserver`.
Add invariant tests that a visible terminal never remains `0x0`, and that xterm `cols/rows` converge with PTY-reported `cols/rows` under split, restore, reload, and mobile-fit scenarios.
Class-level invariant:
- A visible terminal pane must have one authoritative size path from layout measurement to xterm fit to provider resize to applied PTY size. `0x0` and unknown layout measurements are non-authoritative for visible panes. If a resize is skipped because of a mobile lock, PTY lock, hidden state, split layout settle, first mount, provider delay, or dropped acknowledgement, Orca must keep reconciling until the applied PTY size and xterm size converge or the pane enters an explicit degraded state.
Required deterministic hooks:
- Force first measurement to `0x0` during split-right spawn.
- Delay split layout settlement across frames.
- Hold and release mobile-fit or PTY locks while reconcile is active.
- Drop or delay one provider resize acknowledgement.
- Report applied PTY size separately from requested size.
- Switch hidden-to-visible or worktree-to-worktree while a resize is pending.
Required oracle:
- After the fault, assert visible xterm `cols/rows`, provider-reported applied PTY `cols/rows`, and process-observed `stdout.columns/rows` converge.
- Assert a width-sensitive TUI or long-line fixture renders without one-column wrapping.
- Assert typed input reaches the process and echoed output appears after convergence.
- Assert no test is promoted to blocking if it relies on blind `waitForTimeout()` instead of size/lifecycle events or controlled fault release.
### D. Add Provider Contract Tests
Start with a deterministic fake/fault-injection provider, then run the same lifecycle contract against real providers.
The fake provider should be able to:
- Delay and reorder `listSessions()` responses.
- Return `unknown` separately from empty/live/dead.
- Delay attach/replay and resize acknowledgements.
- Kill processes while panes are hidden.
- Drop or duplicate exit/replay/resize events.
- Simulate stale cwd/helper paths.
After the fake provider is stable, run the contract against:
- local PTY
- daemon PTY
- degraded daemon wrapper
- SSH relay
- remote runtime
- WSL path/shell combinations
- mobile session replay
Required scenarios:
- spawn
- hidden spawn
- visible restore
- hidden exit
- reconnect
- resize before/after mount
- cwd deleted
- helper binary moved after upgrade
- process dies while renderer is hidden
### E. Create a Terminal Torture Harness
Add a nightly and pre-release reliability suite that exercises:
- rapid tab create/close
- agent create/close
- split right/down
- reload with split layout
- hidden-to-visible switching
- huge streaming TUI output
- CJK, Vietnamese, Arabic, and emoji input/output
- long paste, link paste, image paste
- browser/terminal focus handoffs
- hibernation wake
- daemon restart/degraded fallback
- mobile connect/disconnect during active terminal traffic
This should record screenshots, terminal snapshots, PTY sizes, lifecycle transitions, and renderer CPU.
Each scenario must also declare its oracle before it can be promoted beyond `experimental`: the observable pass condition, the failure artifact, and whether it is a discovery/stress run or a merge-blocking gate. Stress scenarios without deterministic oracles should stay nightly/pre-release only.
### F. Wrap xterm Addons Behind Safe Adapters
Search, links, WebGL, decorations, and input-protocol helpers should be treated as untrusted code:
- Catch synchronous addon throws.
- Validate all rows/cols/decoration dimensions before calling xterm APIs.
- Disable or degrade an addon for a pane after repeated failures.
- Emit a structured breadcrumb with pane id, provider, terminal size, addon name, and sanitized error.
Class-level invariant:
- xterm addon failures must be contained to the addon and pane. A search, link, WebGL, decoration, input-protocol, or keyboard-navigation error must never unmount the terminal surface, crash the renderer, break focus, or stop typed input/output for the PTY. Invalid dimensions, wrapped-line ranges, zero columns, texture atlas resets, and synchronous callback throws should either be validated away before the addon call or caught and downgraded after the call.
Required deterministic hooks:
- Force zero or near-zero terminal columns.
- Force wrapped search and link ranges across line boundaries.
- Throw from search, link, decoration, and WebGL callback boundaries.
- Simulate WebGL texture/atlas reset or addon initialization failure.
- Invoke next/previous search navigation while the search addon reports an invalid match.
Required oracle:
- The terminal pane remains mounted and focused.
- The failing addon is disabled or downgraded only for the affected pane.
- A structured breadcrumb names the addon and sanitized failure.
- Typed input reaches the process and echoed output appears after the addon failure.
### G. Add Upgrade Fixtures
Keep serialized workspaces and daemon/session state from known old versions:
- pre-#5232 session restore data
- stale daemon cwd
- stale node-pty helper path
- old hibernated agent records
- old mobile session snapshots
- old Windows shell settings
CI should boot the current app against these fixtures and assert no session loss, no blank first pane, and no startup deadlock.
Fixture rules:
- Name the exact production version and build source.
- Store the pre-migration state immutably and copy it to a temp profile before boot.
- Document the expected pre-migration shape and post-migration behavior.
- Include stale daemon/helper/path state when that is the real user upgrade path.
- Treat silent blank replacement as a failure. If exact restoration is impossible, the accepted outcome must be an explicit recoverable/degraded state or user-facing migration notice, not silent session loss.
### H. Add Platform Startup, Input, and Release Artifact Contracts
Some failures look visually healthy while the product is broken. Platform contracts must prove the user action, not only the frame.
Required contracts:
- Terminal rendering/input: on Linux Wayland, Windows ConPTY, and macOS, assert the terminal renders, accepts typed input, echoes process output, scrolls, and preserves focus after GPU/renderer startup paths touched by the PR.
- IME/input: when terminal input handling changes, assert representative CJK, Vietnamese, Arabic, paste, and modifier paths at the lowest deterministic layer available.
- Startup/window lifecycle: packaged app reveals, close button and Alt+F4 work on Windows, startup watchdogs recover when `ready-to-show` stalls, and PATH/shell probes time out with a recoverable state.
- Release artifact: packaged builds include required nested binaries and pass signing/notarization checks, not only top-level app validation.
- Daemon degradation: daemon reachable-but-unable-to-spawn and stale helper/cwd states produce a visible degraded path instead of blank or frozen terminals.
### I. Add an Agent Identity Contract
Provider and terminal tests are not enough for agent/session identity drift. Add a contract for:
- Pane reuse and tab close.
- Worktree switch and sidebar ordering.
- Sleep/wake and hibernation records.
- Provider session id capture and consumption.
- Mobile rows and desktop rows converging after terminal close.
- False running/completed notifications.
- Agent history/filter visibility for restored sessions.
- Provider session replay ownership: workspace activation, launch, restore, sleep/hibernate, dedupe, clearing, and reconnect code must not issue a second resume/launch for a provider session id already owned by an active tab, inactive tab, visible pane, hidden pane, queued pane, pending connection, or live session in the same workspace.
- Session record origins: the same invariant must be proved for live sessions, quit-restored sessions, and worktree-sleep records, including repeat activation of an already-active workspace.
Required agent identity scenarios:
- Activate a workspace with an existing visible live agent pane; assert no duplicate `resume <provider-session-id>` is queued.
- Activate a workspace with an existing hidden/inactive live agent pane; assert the provider session is recognized as represented even before the pane visibly reconnects.
- Re-activate the same workspace repeatedly while agent restore/session discovery is pending; assert the pending ownership claim dedupes all replay attempts.
- Restore a quit-origin session and a worktree-sleep-origin session; assert each is claimed once, survives hidden-to-visible transitions, and does not produce duplicate sidebar/mobile rows.
- Clear or close stale panes while a same-session replacement is queued or pending; assert cleanup cannot erase the newer ownership claim or trigger a second replay.
### J. Introduce Reliability Gates for Terminal/Session PRs
For PRs touching terminal, agent status, sessions, startup, provider routing, or hibernation, require:
- Provider matrix explicitly listed.
- Hidden-pane behavior listed.
- SSH/remote behavior listed.
- Windows/WSL behavior listed when shell, cwd, path, ConPTY, or process launch is touched.
- A regression test that fails against the old behavior.
- A manual or automated stress command if the change touches timing.
- PTY size authority proof when the PR touches terminal spawn, split layout, hidden/visible transitions, mobile fit, resize forwarding, PTY locks, provider resize acknowledgement, or xterm fit. The proof must cover `0x0` quarantine, requested versus applied PTY size, xterm/process size convergence, and a user-visible input/output oracle.
- Agent/provider replay ownership proof when the PR touches agent launch, restore, sleep/hibernate, dedupe, clearing, workspace activation, pane reuse, provider session id capture, sidebar/mobile agent rows, or session history. The proof must cover active versus inactive tabs, visible versus hidden panes, repeat workspace activation, and live, quit, and worktree-sleep session record origins.
- xterm addon containment proof when the PR touches search, links, WebGL, decorations, keyboard terminal shortcuts, synchronized output, or xterm addon registration. The proof must show addon throws and invalid geometry cannot crash the terminal surface, unmount the pane, or break typed input/output.
- The terminal/session checklist should explicitly ask: "Can this change make an already represented provider session look unowned, or replay/resume it a second time?"
- The terminal/session checklist should explicitly ask: "Can this change make a visible pane trust `0x0`, stale requested size, or renderer-only size instead of the size the PTY actually applied?"
- The terminal/session checklist should explicitly ask: "Can an xterm addon callback or invalid addon geometry throw through the terminal surface instead of being contained?"
### K. Make Reliability Tests Predictable and Useful
The goal is not "more tests." The goal is a small set of executable contracts that protect named invariants from real failures.
Required shape for every new reliability gate:
- Name the invariant it protects.
- Name the real issue, PR, or failure mode that motivated it.
- Prove red/green: the gate must fail against the old behavior, a regression fixture, or a small intentionally broken variant before it becomes a trusted blocker.
- Attach a promotion record: invariant id, motivating issue/PR, old-fail evidence, new-pass evidence, exact command, failure output or CI link, deterministic wait condition, owner, runtime budget, and demotion rule.
- Pick the cheapest deterministic layer: pure state-machine or provider-contract tests first, integration tests second, Electron/UI tests only for the small number of golden paths that require the real app shell.
- Avoid sleeps as proof. Wait for observable state, protocol events, lifecycle transitions, PTY size convergence, replay completion, or process exit.
- Record why the test is allowed to block merges: expected runtime, flake history, covered invariant, and owner.
Test maturity levels:
- `experimental`: local or nightly only; useful for shaking out the harness, not allowed to block merges.
- `soak`: runs repeatedly in CI but non-blocking; collects flake rate, runtime, and whether failures are actionable.
- `blocking`: promoted only after red/green proof, stable runtime, and low flake rate.
Flake policy:
- Blocking promotion should require numeric evidence, for example 100 consecutive soak runs or 14 days across required CI platforms, p95 runtime within the gate budget, and zero unexplained flakes. Tune the numbers later, but do not leave "stable" undefined.
- A blocking reliability test that flakes above the agreed threshold should be demoted or fixed quickly; rerun-only culture hides weak gates.
- CI should quarantine or demote a blocking test automatically when it exceeds the threshold over the configured window, open or update an owner issue, and require a fresh soak window before re-promotion.
- A flaky test should be treated as a product or harness bug with an owner, not as background noise.
- If a scenario cannot be made deterministic yet, keep it as soak/manual evidence and do not let it become required CI until the signal is trustworthy.
Deletion policy:
- Delete or demote tests that do not map to an invariant, duplicate a stronger gate, assert implementation details instead of behavior, only fail from timing/environment noise, or require blind sleeps.
- Prefer one high-signal contract test over several UI tests that all exercise the same happy path.
- Track regression coverage by invariant, not by raw test count.
- Maintain a reliability-gates manifest in the repo. Each gate should list invariant id, motivating issue/PR, layer, owner, maturity, runtime budget, flake history link, promotion record, and replacement gate if deprecated. Tests without current metadata should fail review or be demoted.
How to know the program is working:
- Every recent P0/P1 reliability class maps to an invariant and an executable gate or explicitly accepted gap.
- New gates carry red/green evidence.
- Blocking gate flake rate and runtime stay within budget.
- Future incidents identify either a missing invariant, a failed gate, or an explicit accepted gap instead of requiring archaeology.
- Accepted gaps have an owner, reason, affected platforms/providers, issue link, and expiry date. Expired gaps should block future high-risk PRs until renewed or closed.
- The dashboard tracks touched high-risk PRs, gates required, gates implemented, accepted gaps by age/owner, promotions, demotions, and escaped regressions by invariant.
### L. Make Performance and Git Crash Safety First-Class Gates
Correctness gates must include a performance budget when the fix touches terminal throughput, hidden panes, store subscribers, persistence, startup, polling, git/worktree scans, cleanup modals, subprocesses, SSH/WSL providers, or Windows git paths.
Required performance contract:
- Name the user-visible performance symptom that could regress: typing latency, scroll/resize jank, terminal throughput, startup delay, modal loading, CPU, memory, subprocess churn, or crash/reload.
- Name the resource being protected: renderer frame time, event-loop delay, PTY IPC count, xterm writes, serialized bytes, subprocess count, file descriptors, WebGL contexts, webviews, or retained buffers.
- Add a before/after measurement or deterministic count/leak test. Do not rely on "felt faster."
- Keep foreground input echo and control responses latency-sensitive while batching background or high-volume redraw streams.
- Prove hidden snapshot-capable panes do not wake the renderer per PTY chunk/title frame and restore from main-owned buffers when shown.
- Preserve `orca terminal read` pagination, cursor metadata, bounded preview behavior, partial-line handling, truncation flags, and total counts when changing terminal buffering.
- Prove subscriber, persistence, and publication paths avoid unchanged large slices and record serialized byte size when persisted payloads are touched.
- Keep stress/torture runs non-blocking until they have numeric runtime and flake evidence.
Required git crash/perf contract:
- Bound every git subprocess in scan/cleanup paths with a timeout or abort signal, and kill the process tree where applicable.
- Use bounded concurrency and in-flight dedupe; avoid one subprocess per repo x worktree x polling tick.
- Stream progress incrementally to the renderer and remove IPC listeners in `finally`.
- Verify close/reopen/refresh during active or just-settled scans clears loading state, preserves rows, and does not duplicate progress.
- Recheck cleanup candidates immediately before destructive delete, including dirty files, unpushed commits, unknown upstream/base, folder repos, main worktrees, pinned workspaces, running terminals, disconnected SSH, and WSL paths.
- Keep local, WSL, and SSH git providers at parity for timeouts, aborts, errors, and path handling.
- On Windows-risky cleanup changes, validate a real Electron flow when feasible: progress appears before final completion, close/reopen recovers, a clean old worktree delete does not crash, and no orphan Electron/git process remains.
Promotion rule:
- A reliability fix cannot become a blocking gate if it makes the product slower or crashier. The promotion record must include the perf command or measurement, budget, result, platform/provider scope, and any accepted perf gap.
### M. Make the Factory Enforce Those Gates
The factory should turn the reliability gate into default behavior for planning, review, and merge-confidence testing. This should be treated as part of the prevention plan, not as a substitute for the actual test harnesses and CI gates above.
Factory changes to carry forward:
- `brennan-yolo-lite` should require a Reliability regression gate in the design doc whenever terminal, tab, session, provider, startup, upgrade, persistence, packaging, shell, path, or release behavior is touched.
- The gate should force the agent to classify touched P0-capable surfaces: terminal lifecycle, tab creation/binding, dead-session reconciliation, hidden-to-visible resume, PTY identity/routing, PTY geometry including `0x0` quarantine, xterm addon boundaries, IME/paste/redraw behavior, local/daemon/SSH/remote/WSL/mobile provider contracts, attach/replay/resize/exit ordering, startup/upgrade persisted state, daemon survival, renderer/GPU crash containment, packaging/notarization, shell/PATH probing, terminal throughput, hidden-pane output, store subscriber hot paths, persistence payload size, git/worktree scan subprocess churn, cleanup modal progress, and Windows/WSL/SSH git crash risk.
- Completeness verification should block when a touched P0-capable surface has no implemented or explicitly accepted reliability gate.
- `review-code` should treat missing reliability tests or validation gates as findings for these surfaces, with the same review weight as lifecycle, provider, or cross-platform regressions.
- `review-code` should invoke the `perf` skill for slow UI, terminal throughput, high CPU, memory/resource leaks, polling, subprocess churn, startup latency, render churn, or persistence writes.
- `review-code` should invoke the `git-crash-perf` skill for worktree cleanup, git scans/status/listing, destructive removal, cleanup modal progress, SSH/WSL git providers, subprocess timeouts, or Windows git crash risk.
- `brennan-test-changes` should run or explicitly justify the relevant terminal torture, provider contract, startup/upgrade, persisted-state, release, cross-platform, perf, or git crash/perf matrix subset.
Current implementation status:
- Internal factory skill update PR: https://github.com/stablyai/orca-internal/pull/491
- Scope of that PR: add the reliability gate to `brennan-yolo-lite` and expand `review-code` to inspect the recent terminal/session/provider/startup/release failure classes.
- Follow-up needed: once the concrete repo-side harnesses exist, update the skills again to name the exact commands and required CI jobs instead of only naming the expected reliability evidence.
Additional factory follow-up:
- `auto-design-review-fix`: treat missing reliability gates for P0-capable surfaces as design findings; require inverse invariants for cleanup/reconcile/init code.
- `review-code-fix-loop`: require the final loop report to name the touched reliability classes, invariant, gate, and whether both reviewers addressed them. A clean review that never discusses the required reliability class should not count as clean.
- `brennan-test-changes`: add merge-confidence sub-gates for stale snapshot/newborn binding races, hidden-to-visible resume, attach/replay/exit ordering, old-version persisted state, provider/platform matrices, and render-live/input-live checks.
- `pr-review`: classify P0-capable surfaces before review/fix and require the same invariant/test/platform matrix before approval.
- `yolo-lite` and `electron`: require terminal/runtime validation to prove input, scroll, focus, process output, and relevant platform behavior, not only screenshots or visible frames.
- `playwright-reliability-tests`: add a focused skill for writing and reviewing Playwright tests that are evidence-based, low-flake, and tied to named invariants, so browser/Electron tests do not become more noisy checklist coverage.
- `perf`: require measurement-backed review for terminal throughput, hidden pane output, store subscribers, persistence, startup, polling, subprocess churn, resource leaks, and memory growth.
- `git-crash-perf`: require bounded subprocesses, progress streaming, close/reopen recovery, fresh delete preflight, provider parity, and Windows Electron validation for git cleanup/worktree changes.
### N. Add Pane Lifecycle Telemetry and Diagnostics
Each terminal pane should be able to emit a compact lifecycle trace:
- pane id, tab id, worktree id
- provider and connection id
- pty id and binding epoch
- visibility transitions
- spawn/connect timestamps
- resize measurements and forwarded sizes
- liveness snapshot request/response times
- teardown reason
- hibernation/restore reason
This will make future reports much easier to attribute without guessing. It should also be test-consumable: CI should be able to fail on forbidden transitions, stale close attempts, stuck `0x0`, missing input echo, and startup watchdog expiry.
## Attribution Methodology
"Who caused it?" is not reliably answerable from labels alone. This review separates:
- **Confirmed**: the issue body or fix PR explicitly names the introducing PR/change.
- **Likely**: the fix PR identifies the failing subsystem and local git history shows the regressing mechanism was introduced by a specific PR.
- **Area ownership**: the same people are repeatedly fixing or modifying the subsystem, but there is not enough evidence to say they caused a given issue.
- **Systemic**: no single introducer is evident; the problem is a missing invariant, provider contract, or test harness.
## Attribution Findings
### Confirmed Regression Chains
- [#5356](https://github.com/stablyai/orca/issues/5356), session loss on first launch after upgrading from 1.4.65 to 1.4.68, explicitly names PRs [#5234](https://github.com/stablyai/orca/pull/5234) and [#5240](https://github.com/stablyai/orca/pull/5240). Local git history shows both PRs were authored by Jinwoo Hong. Count: Jinwoo 1 confirmed open `P0` regression chain.
- [#5319](https://github.com/stablyai/orca/issues/5319), Linux/Wayland terminal input freeze, is closed, but the fixing PR [#6557](https://github.com/stablyai/orca/pull/6557) says the Wayland-aware GPU path had been removed in [#1344](https://github.com/stablyai/orca/pull/1344). Local git history shows #1344 was authored by Neil. Count: Neil 1 confirmed closed `P1` regression chain.
### Likely Regression Chains
- [#6773](https://github.com/stablyai/orca/issues/6773), new terminal/agent tabs opening and immediately closing, was fixed by [#6796](https://github.com/stablyai/orca/pull/6796) and [#6801](https://github.com/stablyai/orca/pull/6801). Those PRs identify the dead-session reconciler as the cause. Local git history shows [#6514](https://github.com/stablyai/orca/pull/6514), which introduced the focused dead-session reconcile module for hidden exited panes, was authored by Brennan Benson. Count: Brennan 1 likely open `P0` regression chain.
### Area Ownership Without Clear Causation
The recent terminal reliability churn is concentrated among Brennan, Neil, and Jinwoo because they authored many of the fixes and nearby changes. That does not mean each issue was caused by them. It does mean the riskiest ownership surfaces are:
- Terminal lifecycle/reconcile/session persistence: Brennan, Neil, Jinwoo.
- PTY size/layout/reflow: Neil, Jinjing, Jinwoo, Brennan.
- Daemon/degraded spawn/focus/remote lifecycle: Neil, Jinwoo.
- Windows terminal/shell/ConPTY/GPU recovery: Brennan, Neil, Jinwoo.
- Agent status/sidebar/session identity: Brennan, Neil, Jinwoo, Jinjing.
### Current Conservative Count
For currently open `P0`/`P1` issues with a clear or likely PR-caused chain:
- Jinwoo: 1 confirmed open `P0` regression chain.
- Brennan: 1 likely open `P0` regression chain.
- Neil: 0 confirmed open `P0`/`P1` chains from the evidence pulled, plus 1 confirmed closed `P1` chain.
Most open `P0`/`P1` issues do not identify an introducing PR. Treating those as individual blame would be misleading. The stronger conclusion is that Orca has systemic reliability gaps around terminal/session lifecycle, provider contracts, PTY sizing, and upgrade/startup flows.
## Recommended Next Steps
Implementation plan: [`docs/reference/reliability-gates-implementation-plan.md`](./reliability-gates-implementation-plan.md).
1. Deduplicate and update `P0` issues #6873/#6874 and #6773 with the suspected fix status.
2. Add narrow red/green gates for known escapes first: newborn liveness race, PTY `0x0`, xterm addon throw containment, old-version upgrade fixture, and Linux Wayland render-live/input-live smoke.
3. Define test maturity levels, promotion records, numeric flake/demotion policy, and a reliability-gates manifest before making new reliability gates blocking.
4. Build the fake/fault-injection provider contract kit, then run the same contract against local and daemon PTY before expanding to SSH, WSL, remote runtime, and mobile.
5. Add upgrade/startup/release fixtures for pre-#5232 session data, stale daemon/helper paths, packaged app reveal/close, nested notarization, PATH probe timeout, and Windows terminal activation.
6. Add the terminal lifecycle state machine design doc and implement it incrementally behind the known escaped-regression gates.
7. Formalize the PTY size protocol and make `0x0` a quarantined/non-authoritative state for visible panes.
8. Add the agent identity contract for pane reuse, worktree switch, sidebar/mobile rows, provider session ids, stale notifications, and ownership-safe provider session replay across active/inactive tabs, visible/hidden panes, repeated activation, and live/quit/worktree-sleep records.
9. Add performance budgets and measurements to reliability gates that touch terminal throughput, hidden panes, store subscribers, persistence, startup, polling, subprocess churn, git/worktree scans, cleanup modals, SSH/WSL git providers, or Windows git crash risk.
10. Gate terminal/session PRs behind the reliability checklist above, including perf and git crash/perf checks when those surfaces are touched.
11. Keep the factory skill update as the enforcement layer, add the Playwright reliability-testing skill, and make `perf` plus `git-crash-perf` mandatory companion skills for relevant PRs; then revise companion skills once exact repo commands and CI job names exist.
12. Add pane lifecycle traces so future issues can be attributed from evidence instead of archaeology.
@@ -0,0 +1,115 @@
# SSH Typing Latency Under Relay Load
Date: 2026-07-06
## Symptom
Typing in an SSH-host terminal can become extremely slow (hundreds of ms to
seconds per echoed keystroke) while other SSH work is active — file previews,
source-control refresh, search. A quiet SSH shell stays snappy, which made the
reports look unreproducible.
This is distinct from the local warm-switch lag documented in
`terminal-switch-typing-lag-investigation.md` (daemon `listSessions()`
snapshot cost — already fixed; `TerminalHost.listSessions()` is now
metadata-only via `getAppliedSize()`).
## Root Cause
The relay and the Electron client share ONE ordered SSH channel for all
JSON-RPC traffic: PTY input/output, file streams, git responses, search, port
scans. Two mechanisms turned bulk traffic into typing latency:
1. **Relay-side head-of-line blocking (primary).** The `fs.readFileStream`
pump (`src/relay/fs-handler-file-read.ts`) wrote every 256KB chunk (~340KB
framed) into the relay's stdout as fast as local disk reads completed,
ignoring the `write() === false` backpressure signal. A 10MB preview
enqueued ~13.6MB into the pipe at once; a `pty.data` echo emitted
mid-stream queued behind ALL of it. At 2MB/s WAN that is multiple seconds
of echo delay per open file. The reproduced measurement: 4,195,592 bytes
(the entire remaining 3MB test file, framed) queued ahead of one echo
frame.
2. **Client-side O(n²) frame buffering (secondary).** `FrameDecoder.feed()`
in both `src/main/ssh/relay-protocol.ts` and `src/relay/protocol.ts` did
`Buffer.concat([buffered, chunk])` per data event, re-copying the whole
backlog for every ~32KB TCP chunk — ~2MB of memcpy per 340KB frame,
~80MB per 10MB file — on the Electron main thread, delaying `pty:write`
IPC dispatch and echo delivery to the renderer.
## Fix
- **Bulk lane with sink backpressure** (`RelayDispatcher.notifyBulk`):
`fs.streamChunk` frames are serialized per client and each send waits for
the sink's `drain` when `write()` returns `false`. Interactive frames
(`pty.data`) still use plain `notify()` and are admitted immediately —
because the bulk lane keeps the outbound buffer at ~1 frame, an echo jumps
ahead of every not-yet-admitted chunk. Sinks (`relay.ts` stdout + Unix
socket clients) surface `write()`'s boolean and a one-shot
`waitWriteDrain`; every death path (EPIPE, stdin end, detach, dispose)
flushes parked waiters so a pump can never hang on a dead pipe.
- **Credit-based flow control** (`fs.streamAck`): the client requests
streams with `flowControl: 'ack'` and acks each processed chunk; the relay
caps unacked chunks at `STREAM_ACK_WINDOW_CHUNKS = 4` (~1MB raw). This
bounds in-flight bulk bytes even past the relay's own pipe (sshd/TCP
buffers) and paces the relay's base64/JSON encode loop so incoming
keystroke frames get event-loop turns. A parked pump wakes on ack, cancel,
release, client detach, and a 1s staleness recheck.
- **Cross-version compatibility**: old client + new relay → no
`flowControl` param → legacy unpaced pump (still drain-bounded, which is
transparent). New client + old relay → `fs.streamAck` is an unknown
notification and is silently ignored. `STREAM_CHUNK_SIZE` (256KB) is
intentionally unchanged — both sides bake it into chunk offset math, so
changing it would corrupt cross-version streams.
- **FrameDecoder rewrite** (both mirrored protocol files): chunk-list
buffering; each frame is assembled exactly once (one copy) instead of
re-concatenating the backlog per feed.
## What Stays Fast / Unchanged
- Relay PTY output batching (interactive echo fast path in
`src/relay/pty-handler.ts`) is untouched.
- No `listSessions()` / provider inventory was added anywhere near typing,
focus, switch, resume, resize, or render paths.
- Remote file streaming correctness: per-chunk length checks, chunk-count and
byte-count invariants, cancel paths, and the 12MB round-trip integration
test all pass unchanged.
## Regression Coverage
- `src/relay/fs-stream-pty-echo-backpressure.integration.test.ts` — real
mux ↔ dispatcher ↔ FsHandler over a congestible in-memory pipe; asserts
deterministic BYTE bounds (not wall-clock): echo queues behind < 2 framed
chunks when congested (pre-fix: whole file), ack window caps in-flight
chunks, legacy no-ack clients still get complete streams.
- `src/relay/fs-handler-stream.test.ts` — pump parks at the ack window,
resumes per ack, and releases its file handle when cancelled while parked.
- `src/relay/dispatcher.test.ts` — notifyBulk semantics: drain gating,
per-client targeting, dispose releases parked senders, interactive
notify() not gated behind a stalled bulk lane.
- `src/main/ssh/relay-protocol.test.ts` — decoder byte-at-a-time boundary
straddling, oversized-frame resync across odd chunk sizes, and a
no-`Buffer.concat`-during-feed guard that locks out the O(n²) shape.
- `tests/e2e/ssh-docker-relay-perf.spec.ts` — new "busy relay" scenario:
types while two 8MB remote file-read loops and a git.status loop run;
budgets median < 500ms, worst < 2000ms. (On loopback Docker the pre-fix
HOL is only tens of ms — the in-process byte-bound test is the
authoritative red/green; the e2e guards the end-to-end wiring.)
## Residual Gaps
- **Large single-frame responses**: `git.*` responses (stdout capped at
`MAX_GIT_BUFFER` = 10MB → ~13MB framed) and other request/response results
are one atomic frame on the wire; a huge diff can still delay an echo by
its own transfer time. Fixing this requires response chunking (a protocol
change); bounded by the 16MB `MAX_MESSAGE_SIZE`.
- **Outbound direction**: a large `fs.writeFile` request frame (client →
relay) can queue ahead of keystroke `pty.data` frames on `channel.stdin`.
Same shape, opposite direction, rarer trigger (saving a large remote file
while typing).
- `pty.ackData` flow control for PTY output remains unenforced
(`src/relay/pty-handler.ts` — "not yet enforced"); PTY output floods are
already paced by the relay's 8ms batch/16KB slice scheduler.
+393
View File
@@ -0,0 +1,393 @@
# Telemetry Availability
Append-only reference for dashboard authors. Use this file to answer: "From what date is this event or property trustworthy enough to chart?"
This file records operational availability. Event contracts still live in `src/shared/telemetry-events.ts`, and dashboard/query examples live with the dashboard work that introduced them.
## How To Use
- Prefer `dashboard_ready_at_utc` as the lower bound for saved PostHog retention, funnel, and cohort tiles.
- If `dashboard_ready_at_utc` is `TBD`, do not save production cohort or retention tiles. QA/exploratory charts may proceed only after checking first-seen evidence, and their descriptions must say the boundary is not final.
- Use the latest required first-valid timestamp when a dashboard depends on multiple signals. An earlier timestamp may prove one event exists while a join key or depth field is still absent, which silently biases cohorts.
- Do not save a cohort chart from one isolated first-seen row.
- Mention rollout boundaries in saved insight descriptions when querying across pre-rollout data.
- Do not add these timestamps to telemetry payloads. They are repo-side interpretation metadata.
## Boundary Fields
| Field | Meaning |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `code_merged_at_utc` | When the implementation reached `main`. Useful for source history only. |
| `first_released_at_utc` | First release commit/build that contains the code. This is the earliest possible user exposure. |
| `first_seen_at_utc` | First observed PostHog row for the event/property. This proves at least one event flowed. |
| `dashboard_ready_at_utc` | Earliest lower bound for saved dashboards after all required signals have first-valid rows and pass any validation window. |
First-valid means the released app version has been observed, the required event/property fields are populated, joins used by the dashboard are available, and any validation window listed in the rollout entry has passed.
D1+/D3+/D7+ retention means the user fired `app_opened` at least once after 24/72/168 hours from cohort start, matching the existing dashboards' mature-denominator logic. Do not read D3/D7 as exactly-on-day returns, and do not include users whose cohort start is too recent to satisfy the relevant window.
## Cross-Cutting Rules
- `app_opened` is the return marker. It fires once per app session after telemetry consent/gating resolves.
- `nth_repo_added = 0` means the user had no repos at emit time. It is not a missing-value sentinel.
- Missing `nth_repo_added` means the classifier failed soft or the event predates the property. Do not bucket it with `0`.
- Main injects `nth_repo_added` only into schemas that declare the field, and injects onboarding `cohort` only into schemas that declare `cohort`.
- `onboarding_started { cohort: 'fresh_install' }` is the fresh-install anchor. `upgrade_backfill` rows should not be mixed into new-user onboarding funnels.
- Classify a user's fresh-install onboarding cohort from the first `onboarding_started { cohort: 'fresh_install' }` and carry that forward. Existing-user classification can flip after live completion is persisted, so later event-level `cohort` values are not a replacement for the start anchor.
- Numeric onboarding step values are rollout-specific. Prefer `value_kind` when the relevant event/property is available.
- Current `onboarding_step_skipped` means an optional preference/setup step was skipped toward required project setup. It does not mean the user abandoned repo setup.
- `source = 'unknown'` is not a real product surface. It means the caller omitted a source or the value failed schema validation.
- `workspace_created` means create-worktree IPC succeeded. It is not a general "usable workspace exists" or "workspace revealed" marker.
- `add_repo_setup_step_action` is a historical post-add choice-screen event. Current Add Project flows auto-open the default checkout or reveal the project row, so absence of this event after the default-checkout handoff rollout is expected and should not be read as setup abandonment.
- `add_repo_existing_workspaces_detected` is a detection signal for migration opportunity, not proof that a user chose an existing workspace. Current Add Project flows may emit it before automatically opening the default checkout.
- `add_repo_default_checkout_handoff.result = 'revealed_project'` means Orca could not confidently open the default checkout and instead revealed the newly added project row. It is the direct fallback metric for the 2026-06-03 Add Project handoff rollout.
- `agent_started` means PTY spawn succeeded with agent telemetry attached. It is not first-repo activation and does not prove the user sent a prompt.
- `agent_prompt_sent` means a live agent hook observed an explicit non-empty user prompt. It excludes hydrated/replayed status, agent auto-start, bare shells, draft prefill, and hookless sessions; missing rows mean no hook-confirmed interaction was observed, not proof the user never typed.
- Workspace-outcome joins are native Electron coverage unless the query explicitly proves remote/web instrumentation. Remote runtime and web paths can bypass native repo/worktree telemetry, so do not interpret missing workspace outcome rows as product drop-off for SSH, remote, or web users.
## Rollouts
### 2026-05-08 - Repo Cohort Property
Scope: `nth_repo_added` on repo/activation/retention events. Current schemas declare it on `app_opened`, `repo_added`, `add_repo_setup_step_action`, `add_repo_existing_workspaces_detected`, `add_repo_default_checkout_handoff`, `workspace_created`, `workspace_create_failed`, `setup_script_prompt_shown`, `setup_script_prompt_action`, `agent_started`, `agent_prompt_sent`, and `agent_error`. The original rollout covered `app_opened`, `repo_added`, `add_repo_setup_step_action`, `workspace_created`, `workspace_create_failed`, `agent_started`, and `agent_error`; later events have their own first-seen timestamps below.
| Field | Value |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PR | `#1591` |
| Merge commit | `002e2acc38cad262d694049cc86c0962ffd381ce` |
| `code_merged_at_utc` | `2026-05-08T17:50:44Z` |
| First release | `v1.3.42-rc.1` |
| First release commit | `b6dab85aaa0e9b600ec93846824ba815542a1e1e` |
| `first_released_at_utc` | `2026-05-08T17:56:35Z` |
| Earliest `first_seen_at_utc` | `2026-05-08T18:40:00.354Z` on `app_opened` |
| `dashboard_ready_at_utc` | Event-dependent. Use `2026-05-08T18:40:00.354Z` for general `app_opened` repo-count cuts; use `2026-05-08T20:01:06.364Z` or later for first-repo activation cuts requiring `repo_added`. |
PostHog evidence checked at `2026-05-23T23:34:32Z`:
| Event | First seen with `nth_repo_added` |
| --------------------------------------- | ------------------------------------------ |
| `app_opened` | `2026-05-08T18:40:00.354Z` (`1.3.42-rc.1`) |
| `workspace_created` | `2026-05-08T19:15:06.913Z` |
| `agent_started` | `2026-05-08T19:15:07.130Z` |
| `agent_prompt_sent` | `TBD` |
| `repo_added` | `2026-05-08T20:01:06.364Z` (`1.3.42`) |
| `add_repo_setup_step_action` | `2026-05-08T20:01:20.897Z` |
| `workspace_create_failed` | `2026-05-08T23:04:32.315Z` |
| `agent_error` | `2026-05-13T14:13:16.096Z` |
| `add_repo_existing_workspaces_detected` | `2026-05-20T05:33:20.160Z` |
| `add_repo_default_checkout_handoff` | `TBD` |
| `setup_script_prompt_shown` | `2026-05-21T04:09:04.231Z` |
| `setup_script_prompt_action` | `2026-05-21T04:10:31.616Z` |
Dashboard caveats:
- Earlier rows do not have `nth_repo_added`; show them as pre-rollout or exclude them from repo-count cohorts.
- A dashboard that depends on a specific event must use that event's first-seen timestamp, not the earliest `app_opened` timestamp.
- `repo_added nth_repo_added = 1` is the first-repo activation marker.
- `add_repo_setup_step_action` is only representative of the old post-add choice-screen funnel. After the 2026-06-03 default-checkout handoff change below, Add Project no longer shows that choice screen during the normal Git add/clone/create/onboarding paths.
### 2026-05-09 - Onboarding Cohort Injection
Scope: `cohort` on onboarding events. Current schemas declare it on `onboarding_started`, `onboarding_step_viewed`, `onboarding_step_completed`, `onboarding_step_skipped`, `onboarding_tour_outcome`, `onboarding_step4_path_clicked`, `onboarding_step4_path_failed`, `onboarding_task_sources_snapshot`, `onboarding_windows_terminal_snapshot`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, onboarding import/setup events, `onboarding_feature_setup_toggled`, `onboarding_feature_setup_run`, `onboarding_feature_setup_terminal_opened`, and `onboarding_feature_setup_terminal_interacted`. See `src/shared/telemetry-events.ts` for the exact current roster.
The original `#1608` rollout covered `onboarding_started`, `onboarding_step_viewed`, `onboarding_step_completed`, `onboarding_step_skipped`, `onboarding_step4_path_clicked`, `onboarding_step4_path_failed`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, and onboarding import events. Later onboarding events joined the roster by declaring `cohort` in their schemas.
| Field | Value |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PR | `#1608` |
| Merge commit | `318e2b4c2c501b50280dc9e5b11ed34bd899bdd9` |
| `code_merged_at_utc` | `2026-05-09T02:57:18Z` |
| First release | `v1.3.44` |
| First release commit | `e602decf1d35dc54c84eea516fc2b9afdc065cce` |
| `first_released_at_utc` | `2026-05-09T08:05:39Z` |
| Earliest `first_seen_at_utc` | `2026-05-09T09:54:03.038Z` on `onboarding_started { cohort: 'fresh_install' }` (`1.3.45`) |
| `dashboard_ready_at_utc` | `2026-05-09T09:54:03.038Z` for fresh-install onboarding-start cohorts; use later event-specific first-seen timestamps where a tile requires another event. |
Later onboarding cohort events:
| Event | PR | First release | `first_seen_at_utc` |
| ---------------------------------------------- | ---------------------------------------------------- | --------------------------------------- | ----------------------------------------- |
| `onboarding_feature_setup_run` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T05:55:37.248Z` (`1.4.2-rc.4`) |
| `onboarding_feature_setup_terminal_opened` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T05:55:37.261Z` (`1.4.2-rc.4`) |
| `onboarding_feature_setup_terminal_interacted` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T11:47:28.022Z` (`1.4.2-rc.6`) |
| `onboarding_feature_setup_toggled` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-17T03:03:54.535Z` (`1.4.3`) |
| `onboarding_task_sources_snapshot` | `#2275` (`ececd27fd8203a7acb3e430fa016fb4653aaa93c`) | `v1.4.7-rc.1` at `2026-05-19T00:47:11Z` | `2026-05-19T04:18:23.872Z` (`1.4.7`) |
Dashboard caveats:
- Use `onboarding_started { cohort: 'fresh_install' }` as the new-user denominator.
- Keep the single observed `upgrade_backfill` rows out of fresh-install funnels.
- `onboarding_step_completed` had semantic `value_kind` before the tour instrumentation, but `onboarding_step_viewed` and `onboarding_step_skipped` only became semantic-step-safe with the tour telemetry rollout below.
- Later onboarding events joined the `cohort` roster by declaring `cohort` in their schema. Use event-specific first-seen timestamps for tiles that depend on those later events.
- `onboarding_task_sources_snapshot` fires only when the integrations step exits through Continue or Skip to project setup. Viewing integrations and leaving does not emit the snapshot.
- Old numeric step analysis must be scoped to the flow version being analyzed.
### 2026-05-14 - Feature Wall Base Telemetry
Scope: first feature-wall tour event family before the inline onboarding tour. This added base open/close and tile activity telemetry. Historical `feature_wall_closed` rows from this rollout have `dwell_ms`; the source/depth fields needed for tour cohort retention only arrive in the later tour telemetry rollout.
| Field | Value |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| PR | `#1772` |
| Merge commit | `fdf7d9e97a46f27b3667d9bae6c8aca5d11345d8` |
| `code_merged_at_utc` | `2026-05-14T22:20:50Z` |
| First release | `v1.4.2-rc.1` |
| First release commit | `c66e9801fe5f159cac23564ec7c028957973692a` |
| `first_released_at_utc` | `2026-05-14T22:23:46Z` |
| `first_seen_at_utc` | `2026-05-15T06:15:30.263Z` on `feature_wall_opened { source: 'popup' }` (`1.4.2-rc.4`) |
| `dashboard_ready_at_utc` | Event-dependent. Useful for base feature-wall usage after first-seen rows, not for inline-tour cohort retention. |
PostHog evidence checked at `2026-05-23T23:34:32Z`:
| Event/property | `first_seen_at_utc` |
| ------------------------------------------------- | ----------------------------------------- |
| `feature_wall_opened { source: 'popup' }` | `2026-05-15T06:15:30.263Z` (`1.4.2-rc.4`) |
| `feature_wall_tile_focused` | `2026-05-15T06:15:31.073Z` (`1.4.2-rc.4`) |
| `feature_wall_closed` with legacy `dwell_ms` only | `2026-05-15T06:18:22.677Z` |
| `feature_wall_tile_clicked` | `2026-05-17T04:35:04.142Z` (`1.4.3`) |
| `feature_wall_opened { source: 'help_menu' }` | `2026-05-17T07:02:23.278Z` (`1.4.3`) |
Dashboard caveats:
- Do not infer inline onboarding tour cohorts from this rollout.
- Historical `feature_wall_closed` null `source`/depth fields are pre-rollout absence, not product behavior.
### 2026-05-23 - Inline Tour Surface
Scope: optional "Explore Orca" tour during onboarding and the Help menu entry point. This shipped the product surface, not the full retention cohort instrumentation. It also added `source = 'onboarding'` to the feature-wall open source enum and added feature-wall group/feature/docs click events with source.
| Field | Value |
| -------------------------- | ---------------------------------------------------------------------------- |
| PR | `#2652` |
| Merge commit | `669ade23133b9905fff1b6ed22755ee2911ea517` |
| `code_merged_at_utc` | `2026-05-23T19:21:14Z` |
| First release | `v1.4.23-rc.0` |
| First release commit | `1f1171e0dee5d0fbf969de56845882bf8deb9037` |
| `first_released_at_utc` | `2026-05-23T20:14:38Z` |
| First app version observed | `1.4.23-rc.0` at `2026-05-23T20:54:12.343Z` |
| `dashboard_ready_at_utc` | `TBD`; do not use this surface-only rollout for tour cohort retention tiles. |
Dashboard caveats:
- `feature_wall_opened { source: 'onboarding' }` is a release-availability signal for the inline surface, not the canonical tour cohort assignment.
- Use this boundary only for exploratory checks that do not require the new outcome/depth fields.
- Tour cohorts are observational and self-selected. Do not use causal "lift" or "impact" language unless a separate experiment supports it.
### 2026-05-23 - Onboarding Tour Retention Telemetry
Scope: low-cardinality telemetry that makes inline-tour cohort retention dashboards possible.
Current status: the inline first-run onboarding tour was later removed from active onboarding after PR #2734 moved these education/setup moments to contextual feature tours and the Getting started with Orca guide. The historical schemas still accept seven-step `tour`/`agent_setup` onboarding rows for compatibility, but current active onboarding should not emit new `onboarding_step_* { value_kind: 'tour' }` rows.
Added/changed signals:
- `onboarding_tour_outcome` with `outcome`, intro/tour duration fields, optional tour depth fields, `advanced_via`, and injected onboarding `cohort`.
- `onboarding_step_viewed.value_kind` and `onboarding_step_skipped.value_kind`, including `value_kind = 'tour'`.
- `feature_wall_closed` optional `source`, `exit_action`, `furthest_step`, `last_group_id`, and bounded visited/completed depth counts.
- `feature_wall_opened { source: 'onboarding' }` through the inline tour surface.
| Field | Value |
| ------------------------ | ------------------------------------------ |
| PR | `#2713` |
| Branch commit | `9115143927dce38547545a07e58b85d17796fd7a` |
| Merge commit | `b9c55bb07127f799528761ec31d7e2b7f8598c07` |
| `code_merged_at_utc` | `2026-05-23T21:15:11Z` |
| First release | `v1.4.23-rc.1` |
| First release commit | `c74765922e770c6540496735a5a2be838457256a` |
| `first_released_at_utc` | `2026-05-23T21:16:38Z` |
| `first_seen_at_utc` | `TBD` |
| `dashboard_ready_at_utc` | `TBD` |
Required readiness signals:
| Signal | `first_seen_at_utc` |
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `onboarding_tour_outcome` with valid `outcome` and joinable fresh-install start | `TBD` |
| `onboarding_step_viewed { value_kind: 'tour' }` | Historical only; no new active-onboarding rows after the PR #2734 education handoff. |
| `feature_wall_closed` with non-null `source`, `exit_action`, and expected depth fields | `TBD` |
QA/opportunistic signals:
| Signal | `first_seen_at_utc` |
| ------------------------------------------------ | ------------------------------------------------------------------------- |
| `onboarding_step_skipped { value_kind: 'tour' }` | Historical only; do not gate dashboard readiness on natural skip traffic. |
| `feature_wall_opened { source: 'onboarding' }` | `TBD`; surface availability only. |
PostHog evidence checked at `2026-05-23T23:34:32Z`:
- `1.4.23-rc.0` had app traffic from `2026-05-23T20:54:12.343Z` through `2026-05-23T22:59:47.599Z`.
- No `1.4.23-rc.1` or `1.4.23-rc.2` app traffic was observed in the checked query window.
- No rows were observed for `onboarding_tour_outcome`, `onboarding_step_viewed { value_kind: 'tour' }`, `onboarding_step_skipped { value_kind: 'tour' }`, `feature_wall_opened { source: 'onboarding' }`, or `feature_wall_closed` with the new source/depth fields.
- No rows were observed for `feature_wall_group_selected`, `feature_wall_feature_selected`, or `feature_wall_docs_clicked` in the checked query window.
Dashboard readiness rule:
Set this rollout's `dashboard_ready_at_utc` to the latest first-valid timestamp across the required readiness signals, then use that as the lower bound. If a query uses a local variable, name it `tour_telemetry_ready_at_utc` and assign it to the same value. After first-seen rows exist, keep saved cohort tiles in QA/exploratory mode until there is at least a 24-hour validation window where reached-tour users reconcile to exactly one primary inline cohort and closed tour sessions include depth fields.
Invalid before this rollout's `dashboard_ready_at_utc`:
- Tour cohort onboarding completion/progress.
- Corrected onboarding outcome split by tour cohort.
- Tour depth before abandonment.
- Skipped-tour later Help recovery.
- D1/D3/D7 retention by tour cohort, until cohorts are also old enough for each maturity window.
- Numeric-step max-progress charts unless segmented by rollout or app version.
Primary cohort interpretation:
- Historical denominator for inline completed/partial/skipped rates: `onboarding_step_viewed { value_kind: 'tour' }` after this rollout's `dashboard_ready_at_utc`. Do not use this as a current active-onboarding readiness signal after the PR #2734 education handoff.
- `onboarding_tour_outcome.outcome = 'completed_inline'` means the user completed the inline tour during fresh onboarding.
- `onboarding_tour_outcome.outcome = 'started_partial'` means the user started the inline tour but the fresh onboarding session resolved without inline completion.
- `onboarding_tour_outcome.outcome = 'skipped_intro'` means the user reached the tour intro and skipped without starting the inline tour.
- Users who started onboarding but never reached the tour intro are `pre_tour_abandoned`, not `unresolved_inline_outcome`.
- Users who reached the tour intro but have no outcome event are `unresolved_inline_outcome`; monitor this as a telemetry QA gap.
- Later Help menu tour usage is an overlay on the user's inline cohort. It does not replace the primary inline outcome.
- `completed_inline` wins over earlier partial state if the user returns and completes during the fresh onboarding session.
- `started_partial` resolves only after the user started the inline tour and the fresh onboarding session resolves without inline completion.
- `skipped_intro` intentionally has no `tour_dwell_ms` or depth fields.
Feature-wall close interpretation:
- `feature_wall_closed.dwell_ms` is session-close dwell time.
- `feature_wall_closed.source` is the entry point (`onboarding`, `help_menu`, `popup`, or `unknown`).
- `exit_action = 'onboarding_continue'` means the inline onboarding tour ended through Continue to project setup.
- `exit_action = 'done'` means a non-onboarding tour session ended through Done.
- `exit_action = 'dismissed'` is the default close/unmount path.
- Depth fields are per explicit tour session. Persisted completion state can affect UI progress but must not be interpreted as current-session depth.
- Missing/null `feature_wall_closed.source` or depth fields mean the row predates the expanded schema, came from an older app version, or failed field coverage. Do not bucket null with `source = 'unknown'`; exclude it from source/depth cohort tiles or show it as a telemetry coverage gap.
### 2026-06-02 - Active Onboarding Step Removal
Scope: active first-run onboarding no longer emits the `agent_setup` or `tour` semantic steps. The removed "Set up Orca for agents" and "Explore Orca" education/setup moments are covered by PR #2734 through contextual feature tours and the Getting started with Orca guide.
This is a product-flow and telemetry-interpretation boundary, not a new event rollout. Historical onboarding schemas still accept seven-step `agent_setup` and `tour` rows so old data remains queryable, but dashboard authors should not expect new active-onboarding rows for those semantic steps after this rollout.
| Field | Value |
| ------------------------ | ------------------------------------------------------------------------------- |
| PR | `#4445` |
| Merge commit | `TBD` |
| `code_merged_at_utc` | `TBD` |
| First release | `TBD` |
| First release commit | `TBD` |
| `first_released_at_utc` | `TBD` |
| `first_seen_at_utc` | N/A |
| `dashboard_ready_at_utc` | Event-dependent; use this as a cutoff only after the PR is merged and released. |
Dashboard caveats:
- Treat `onboarding_step_* { value_kind: 'agent_setup' }`, `onboarding_step_* { value_kind: 'tour' }`, and `onboarding_tour_outcome` as historical first-run onboarding signals after this rollout.
- Do not use absence of new `agent_setup` or `tour` onboarding rows as a drop-off signal; those steps no longer exist in active onboarding.
- Segment numeric onboarding step analysis across this boundary. The active final step changed from seven-step onboarding to the five-step active flow.
- Continue using `contextual_tour_shown` and `contextual_tour_outcome` from PR #2734 for current feature-education exposure and outcome analysis.
### 2026-06-03 - Final Code Onboarding Step Removal
Scope: active first-run onboarding no longer emits the final code/project picker step. The notifications step is now the final step, and completing it opens the Add Project modal.
This is a product-flow and telemetry-interpretation boundary, not a new event rollout. Historical onboarding schemas still accept five-step rows so old data remains queryable, but dashboard authors should not expect new active-onboarding rows for the removed final code/project picker step after this rollout.
| Field | Value |
| ------------------------ | ------------------------------------------------------------------------------- |
| PR | `#4524` |
| Merge commit | `TBD` |
| `code_merged_at_utc` | `TBD` |
| First release | `TBD` |
| First release commit | `TBD` |
| `first_released_at_utc` | `TBD` |
| `first_seen_at_utc` | N/A |
| `dashboard_ready_at_utc` | Event-dependent; use this as a cutoff only after the PR is merged and released. |
Dashboard caveats:
- Treat `onboarding_step_*` rows for the removed final code/project picker step as historical first-run onboarding signals after this rollout.
- Segment numeric onboarding step analysis across this boundary. At this boundary, the active final step changed from the five-step active flow to `ONBOARDING_FINAL_STEP = 4`; later onboarding step rollouts may supersede that final-step value.
- Do not use absence of new final code/project picker rows as a drop-off signal; that step no longer exists in active onboarding.
### 2026-06-03 - Add Project Default Checkout Handoff
Scope: Add Project handoff telemetry and interpretation change. The Git add/clone/create/onboarding flows no longer show the post-add setup-choice screen; they close the add modal and open the project/default checkout when available. If no default checkout is available, they reveal the project row instead.
`repo_added` remains the add/import marker. `add_repo_existing_workspaces_detected` remains a low-cardinality detection event for pre-existing non-main workspaces on local folder, runtime server path, and SSH server path adds. `add_repo_default_checkout_handoff` is the direct rollout-health event for the automatic handoff decision.
| Field | Value |
| ------------------------ | ------------------------------------------------------------------------------------- |
| PR | `#4530` |
| Merge commit | `TBD` |
| `code_merged_at_utc` | `TBD` |
| First release | `TBD` |
| First release commit | `TBD` |
| `first_released_at_utc` | `TBD` |
| `first_seen_at_utc` | `TBD` on `add_repo_default_checkout_handoff` |
| `dashboard_ready_at_utc` | Use the first release boundary once known for charts that compare setup-choice usage. |
Dashboard caveats:
- Treat `add_repo_setup_step_action` rows after this rollout as historical compatibility or stale modal cleanup only, not the normal Add Project funnel.
- Do not build current-flow conversion funnels that require `repo_added -> add_repo_setup_step_action`; the normal next step is an automatic workspace reveal/open, not a tracked user choice.
- Use `add_repo_existing_workspaces_detected` to estimate how often added projects had non-main existing workspaces, but do not infer the user selected "use existing worktrees" because that choice no longer exists in the normal flow.
- Use `add_repo_default_checkout_handoff` for the current handoff outcome. `result = 'opened_default_checkout'` is the expected path; `result = 'revealed_project'` is the graceful fallback. Break down fallback rows by `source` and `reason`.
### 2026-06-10 - Repo Added Git-vs-Folder Signal
Scope: `repo_added.is_git_repo` replaces the retired `onboarding_completed.is_git_repo` split for git-vs-folder analysis. Project selection moved out of onboarding in the 1.4.46 flow, so `onboarding_completed` now fires before any repo is chosen. After that boundary, the old `onboarding_completed.is_git_repo` value is not a valid git-vs-folder signal.
`repo_added.is_git_repo` is sourced from git detection at the add point. It is optional so SSH/remote paths that genuinely cannot determine git-ness can omit the property instead of defaulting to `false`.
| Field | Value |
| ------------------------ | ------------------------------------------------------------------------------------------- |
| PR | `#5121` |
| Merge commit | `TBD` |
| `code_merged_at_utc` | `TBD` |
| First release | `TBD` |
| First release commit | `TBD` |
| `first_released_at_utc` | `TBD` |
| `first_seen_at_utc` | `TBD` on `repo_added.is_git_repo` |
| `dashboard_ready_at_utc` | `TBD`; use only after first-seen rows exist and field coverage has been checked in PostHog. |
PostHog evidence checked at `2026-06-10T19:00:00Z`:
- Dashboard tile "Fresh-install onboarding completion over time" (`JlIt5J1N`, insight id `9076383`, project `406068`) showed the git-repo share collapse to about 4% while plain-folder completions spiked to about 88% on 2026-06-05.
- Raw `onboarding_completed.is_git_repo` counts by `app_version` showed a version cliff: versions through `1.4.45` were about 80% true, while `1.4.46`, `1.4.47`, and `1.4.48` had zero true rows in the sampled data.
Dashboard caveats:
- Treat `onboarding_completed.is_git_repo` as historical only after app version `1.4.45`.
- Do not stitch historical `onboarding_completed.is_git_repo` and new `repo_added.is_git_repo` series without an explicit version boundary and label change; they are emitted at different funnel moments.
- Repoint dashboard tile `JlIt5J1N` to use `repo_added.is_git_repo` once the new field is observed in release telemetry.
- Omitted `repo_added.is_git_repo` means unknown/degraded detection, not plain folder. Only explicit `false` means plain folder.
### 2026-06-16 - Windows Terminal Preferences Onboarding Step
Scope: Windows first-run onboarding adds a terminal preferences step before notifications. The step lets users choose the default Windows terminal shell and right-click paste/menu behavior before their first project handoff.
`onboarding_step_*` rows can now emit `value_kind = 'windows_terminal'` at step `4`. Notifications move to step `5`, so `ONBOARDING_FINAL_STEP = 5` for current active onboarding. Non-Windows clients skip the Windows terminal step but still persist through the skipped step so resumed onboarding lands on notifications. `onboarding_windows_terminal_snapshot` records the low-cardinality selected shell bucket, right-click behavior, exit action, duration, and advance method when the visible Windows terminal step exits.
| Field | Value |
| ------------------------ | -------------------------------------------------------------------------------------- |
| PR | `#5488` |
| Merge commit | `68abadba8198c627fb642c41e54937c04ccddfe8` |
| `code_merged_at_utc` | `2026-06-16T21:07:26Z` |
| First release | `TBD` |
| First release commit | `TBD` |
| `first_released_at_utc` | `TBD` |
| `first_seen_at_utc` | `TBD` on `onboarding_step_viewed { value_kind: 'windows_terminal' }` and `onboarding_windows_terminal_snapshot` |
| `dashboard_ready_at_utc` | `TBD`; use only after first-seen rows exist and Windows/non-Windows split plus snapshot coverage are verified. |
Dashboard caveats:
- Segment numeric onboarding step analysis across this boundary. Step `4` is Windows terminal preferences in the current flow, but was notifications in the previous active flow.
- Use `value_kind` rather than numeric `step` when comparing notifications or Windows terminal setup across releases.
- Non-Windows users can have persisted `lastCompletedStep` values that include the skipped Windows step; do not treat that as evidence they viewed the Windows terminal page.
- `onboarding_windows_terminal_snapshot.default_shell = 'other'` means Orca could not bucket the persisted setting. It is not a raw shell path and should be monitored as telemetry quality, not a product choice.
## Updating This File
When adding or changing telemetry that dashboard authors will depend on:
1. Add or update one rollout entry in this file.
2. Record PR, merge commit, first release, release commit, `first_released_at_utc`, `first_seen_at_utc`, and `dashboard_ready_at_utc`.
3. Include the app version/build on first-seen rows when it is available.
4. Add `PostHog evidence checked at ...` with the UTC query time.
5. If dashboard readiness depends on multiple signals, add a required readiness signal table and set `dashboard_ready_at_utc` only after all of them are first-valid.
6. Add dashboard caveats for pre-rollout rows, missing/null fields, and maturity windows.
7. Keep product-specific facts here, not in agent instructions or telemetry payloads.
@@ -0,0 +1,132 @@
# Cold-park reveal cost — measured
Companion to [`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md).
Quantifies the reveal latency a user pays when returning to a terminal whose
hidden view was cold-parked, so the keep-warm policy is tuned from data rather
than guessed. Motivated by field reports that "terminal mounting is jumpy —
switching to a worktree after a while takes ~1s".
## How to reproduce
```bash
pnpm bench:cold-park-reveal -- --cycles=12 # empty shell
pnpm bench:cold-park-reveal -- --cycles=10 --scrollback-lines=5000
pnpm bench:cold-park-reveal -- --cycles=10 --scrollback-lines=20000
```
The bench (`tools/benchmarks/terminal-cold-park-reveal-bench.mjs`) drives the
real dev app over CDP. Each cycle times a **cold** reveal (tab confirmed parked
via `window.__terminalParkingDebug.parkedTabIds()` before revealing) and a
**warm** reveal (revealed inside the hot-retain window, confirmed not parked),
so the coldwarm delta is the isolated cost of parking. It shrinks the 30s
hysteresis with `ORCA_E2E_TERMINAL_PARKING_DELAY_MS` and roots userData under a
short `~/.ocpb` path — the default `/var/folders` tmp path overruns the macOS
104-char Unix-socket limit, the daemon fails to bind, and terminals fall back to
non-snapshot PTYs that never park.
Phases per reveal (ms from the switch-back click): `activationMs` (store active
worktree flips), `ptyBindMs` (revealed pane has a bound pty — xterm remounted
and attached), `paintSettleMs` (two RAFs after ptyBind).
## Results (macOS, dev app, medians / p95)
| Scrollback | warm paintSettle | cold paintSettle | **cold-park penalty** |
| ---------- | ---------------- | ---------------- | --------------------- |
| empty shell | 104 ms | 264 / 298 ms | **+160 ms** |
| 5 000 lines | 131 ms | 318 / 423 ms | **+187 ms** |
| 20 000 lines | 102 ms | 266 / 286 ms | **+164 ms** |
All cold samples confirmed parked (`parkedConfirmed = N/N`); all warm samples
confirmed not parked.
## Finding
**The cold-park reveal penalty is ~160190 ms and effectively flat across buffer
size.** Growing scrollback 0 → 20 000 lines did not grow the penalty (the 20k
run is within noise of empty). So the cost is **fixed remount overhead** — React
subtree teardown/rebuild, fresh xterm construction, WebGL attach, fit, and the
reattach reset — **not** daemon snapshot replay, which scales fine.
Implications for tuning:
- The lever is **how often a remount happens**, not how much is replayed.
Widening the keep-warm working set (worktree hot-retain limit is 4, tabs 12)
and never parking the last-active tab per worktree remove remounts entirely
for the common switch-away-and-back, which is worth more than shaving the
~170 ms remount itself.
- Pre-warming a predicted reveal (sidebar hover, jump-palette selection) hides
the fixed cost behind the navigation instead of eliminating it.
- This synthetic terminal is a plain shell. A live agent TUI in the alternate
screen pays additional reattach-reset + mode-rehydrate work on reveal (see the
Grok-scrollback reattach path), so real-world cold reveals of a busy agent
pane can exceed these numbers — the ~170 ms here is a floor for the mount
overhead, not a ceiling for the full experience.
## Is parking worth having? (benefit side)
`tools/benchmarks/terminal-cold-park-resource-bench.mjs`
(`pnpm bench:cold-park-resource`) A/Bs the `terminalHiddenViewParking` setting
in one session: mount N worktree terminals, background all but one, then read
resources with parking off vs on. Measured at 8 backgrounded worktrees
(5 000-line scrollback each):
| metric | parking off | parking on | released |
| ------ | ----------- | ---------- | -------- |
| mounted pane managers | 9 | 1 | **8** |
| DOM nodes | 2 941 | 1 389 | **1 552** |
| attached WebGL contexts | 1 | 1 | **0** |
So parking's unique win is **releasing every hidden terminal's mounted view**
8 xterm instances + their DOM subtrees (~1 550 nodes) here. At xterm's default
5 000-row scrollback, each terminal buffer alone is ≥ ~45 MB of typed-array
cell data (12 bytes/cell × 80 cols × 5 000 rows), so the renderer-memory floor
is on the order of **several MB per hidden terminal** — tens of MB across a
many-worktree session, before DOM and addon overhead.
Two honest caveats on the numbers:
- The **WebGL-context budget is NOT parking's win.** Even with parking off, only
one context was attached across 9 mounted terminals — the separate
WebGL-suspend-on-hide path (`suspendRendering`) already releases hidden panes'
contexts. So the #6874 context-exhaustion protection stands with or without
parking; parking's benefit is renderer memory + DOM, not contexts.
- The measured JS-heap delta was small (~1.7 MB) because detached xterm
typed-array buffers GC lazily and the first measurement ran before they were
reclaimed. The bench was upgraded to force multi-pass GC and read whole-app
process memory (`window.api.memory.getSnapshot`), but a machine under disk +
memory pressure could not complete the upgraded run (esbuild dev-service
crashes). Re-run the upgraded bench on a healthy machine to get the settled
whole-app memory figure; the pane-manager / DOM release above is the robust,
reproducible part.
### Verdict
Parking earns its keep for the **many-worktree power user** (the reporter's
profile): it is the only mechanism that frees hidden terminals' view memory and
DOM, which is the dominant renderer cost at scale. It does **not** provide the
GPU-context protection (that is suspend-on-hide's job). The reveal price is a
flat ~170 ms remount. Net: keep it, and tune the keep-warm caps so the common
few-worktree rotation never pays the remount.
### Tuning applied
Because the reveal cost is a fixed remount, the levers are all about
**frequency**, not replay size. Three changes to
`terminal-hidden-view-parking.ts`:
- **Worktree hot-retain limit 4 → 8.** Eight covers the ordinary working set;
at the ~45 MB renderer floor per hidden worktree that is ~1620 MB worst
case to eliminate remounts for the common rotation. Beyond 8, parking still
reclaims for the heavy tail it was built for.
- **Hot-retain TTL 5 min → 15 min** (worktree and tab). The cap, not the clock,
is the primary evictor now; 5 min was aggressive enough that a meeting-length
absence parked the whole warm set. The tab count cap stays 12 (already
generous).
- **Last-active exemption.** The single most-recently-hidden worktree/tab is
never parked, regardless of TTL or cap, so returning to the view you just
left is always instant. Implemented in `selectIdsBeyondHotRetain`; it holds
one warm slot and counts against the cap.
Not done (deliberately): pre-warm on predicted reveal — it hides the cost
behind navigation rather than removing it and adds hover/prediction complexity;
and the 30 s cold-park delay is unchanged (it is the quick-flip guard).
@@ -0,0 +1,119 @@
# Terminal Hidden View Parking
Status: Shipped — Phase 1 of the terminal model/view architecture, kill switch
`terminalHiddenViewParking` (default on). See
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) for the
invariants this design extends and the full phase list.
## Problem
Hidden terminal panes keep a full renderer xterm instance alive (buffer,
scrollback, DOM, addons). At many-worktree scale this is the dominant renderer
memory cost, and it forces every hidden byte through renderer-side write/skip
decisions. The main-process model (daemon + runtime headless emulators) already
ingests every byte and can serve restorable snapshots, so the renderer view for
a long-hidden pane is redundant state.
A previous attempt shipped and was reverted the same day. The post-mortem
finding: parking unmounted the pane component, which also tore down the
renderer's PTY byte parsers — and those parsers are the only source of bell
notifications, title-transition agent-complete notifications, and tab titles.
A parked worktree whose agent finished would never notify. This design keeps
those side effects alive while parked.
## Design
### Park policy (renderer)
A pure policy module decides which hidden terminal tabs may park:
- Cold-park hysteresis: a tab must be hidden for 30s before parking.
- Hot-retain working set: recently visible worktrees/tabs are retained
(15 minutes; up to 8 worktrees / 12 tabs) so quick tab switches never pay a
re-hydrate. The single most-recently-hidden (last-active) worktree/tab is
exempt from both the retain TTL and the count cap, so switching back to the
view you just left is always instant regardless of how long you were away.
See `terminal-cold-park-reveal-cost.md` for the data behind these caps.
- Eligibility excludes: visible panes, hidden-measuring startup probes,
activity-portal panes, tabs with pending startup commands or pending
activation spawns, floating-panel tabs, and any tab whose PTY is not
snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded).
- Kill switch: `settings.terminalHiddenViewParking === false` disables parking
entirely.
### Park mechanics
Parking a tab unmounts its `TerminalPane` React subtree (the overlay layer
renders null for parked tabs). This is the same teardown that tab-group moves
already exercise: transports detach but the PTY session, daemon model, and tab
state all survive. The xterm instance, its buffers, DOM, and WebGL/addon
resources are released.
### Parked watcher (the piece the reverted attempt lacked)
While a tab is parked, a pane-less watcher
(`parked-terminal-byte-watcher.ts`) keeps the pane's side effects alive. Its
consumption mode is decided once at watcher start:
- **Main side-effect authority on (default):** the watcher is purely
fact-driven — it registers exactly one `pty:sideEffect` fact consumer and
parses no bytes. Titles, agent working/idle/exited transitions, BEL
attention, and PR links arrive as main-tracker facts and drive the same
policy callbacks a mounted pane uses. With the hidden-delivery gate also on,
the watcher marks the PTY hidden so main stops renderer byte delivery
entirely; the DECSET 2031 color-scheme subscribe arrives as main's
`2031-subscribe` fact and the watcher replies out-of-band via
`transport.sendInput`.
- **Kill switch off:** the watcher subscribes to raw bytes through the
dispatcher sidecar mechanism (the same mechanism background agent launches
use) and runs the transport-level byte parsers with no xterm — OSC 0/1/2
titles (all-titles ordering, live-path normalization), the title-transition
agent tracker (completion notification, prompt-cache timer), the OSC-aware
stateful BEL detector, the GitHub PR link scan, and a dedicated DECSET 2031
byte responder (`parked-terminal-mode2031-responder.ts`, whose
`subscribeToPtyData` registration doubles as the delivery-interest signal).
The two modes drive one shared policy-callback block, so flipping the kill
switch never changes notification semantics. Main's synthetic
agent-title/permission frames feed the main tracker directly and arrive as
facts; the legacy synthetic `pty:data` copy exists only in kill-switch-off
mode.
Out of scope while parked: OSC 52 clipboard writes. Terminal queries inside
hidden-dropped chunks are answered by main's model responder
([`terminal-query-authority.md`](./terminal-query-authority.md)); in
kill-switch-off byte mode only the 2031 reply is answered and Command Code
output is not scraped, matching the pre-gate status quo.
### Reveal
Revealing a parked tab remounts the pane subtree and rides the existing
reattach path: fresh xterm via `openTerminal` (unicode provider activation
before any write), daemon model snapshot > relay replay > cold restore
precedence, replay-guarded so snapshot-embedded queries never answer, then
`POST_REPLAY_REATTACH_RESET` hygiene, fit, and PTY resize. The watcher is
disposed before the pane handlers re-register.
## Invariants
1. PTY reads never stop; parking only changes renderer-side view lifetime.
2. Bell, agent-completion, title, and PR-link side effects keep working while
parked (watcher parity tests).
3. Reveal shows model-correct output (visual gates: hidden TUI restore, long
table, rendering golden) and accepts input immediately.
4. Sleep/wake, pane close, and PTY restart while parked must not leak watchers
or strand parked state.
5. Memory: parked tabs hold no xterm buffers; renderer memory scales with
visible panes.
## Relation to later phases (all shipped)
Side-effect authority in main (Phase 3) replaced the watcher's byte parsing
with the `pty:sideEffect` fact consumer; the hidden-delivery gate (Phase 4)
stops hidden byte delivery in main, moving the parked 2031 reply from the
byte sidecar to the `2031-subscribe` fact; the model query responder
(Phase 5) answers queries in hidden-dropped chunks. The watcher's byte-parser
mode survives only behind the kill switches. Parking still excludes
remote-runtime and SSH PTYs (no local snapshot to restore from); the watcher
would return as a byte parser only if remote-runtime tabs — whose bytes never
transit local main — ever became parkable.
@@ -0,0 +1,218 @@
# Terminal Model/View Contract
## Goal
Terminal output should have one authoritative model path and many disposable
views. A renderer xterm is the fast interactive view, but it must not be the
only place hidden, remote, mobile, SSH, or CLI-visible terminal state exists.
This contract defines the boundary the shipped terminal stack implements — and
that future terminal work must preserve — without changing the query-response
behavior that real shells and TUIs depend on. See [Architecture
Status](#architecture-status) for the shipped phases.
## Terms
- **PTY stream:** Ordered bytes read from a local PTY, daemon PTY, SSH relay PTY,
or remote runtime PTY.
- **Terminal model:** Main/runtime-owned state derived from PTY bytes. Today this
is mostly the headless emulator plus retained read transcript state.
- **Terminal view:** A renderer xterm, mobile subscriber, remote desktop
subscriber, or CLI read page consuming model state and live output.
- **Snapshot:** A bounded model serialization that can restore a view without
replaying an unbounded byte log.
- **Transcript:** The retained output contract for `orca terminal read`; it is
line/cursor oriented and distinct from a screen snapshot.
## Non-Negotiable Invariants
1. PTY reads do not stop to protect renderer performance. Backpressure may bound
delivery to views, but terminal state, notifications, titles, and agent
status keep advancing from the PTY stream.
2. Active visible terminal input/output stays on the lowest-latency path. Bulk
hidden or background output must not delay keystroke-sized foreground redraws.
3. Hidden views do not own unbounded output memory. Main's hidden-delivery
gate drops renderer-bound bytes for hidden-marked PTYs after model
ingestion and emits an out-of-band restore marker
(`pty:modelRestoreNeeded`) so the view restores from the model on reveal.
With the gate's kill switches off, hidden bytes ride a bounded renderer
queue whose overflow latches the same model restore.
4. Returning to a hidden or slept terminal must show model-correct output. A
stale or replaced view may be cleared and replayed from a snapshot, but it
must not show a warning fallback when model recovery is available.
5. Snapshots and live bytes have ordering metadata. A view restore must not
duplicate bytes already included in the snapshot or drop bytes that arrived
after it. Main buffer snapshots report the pending-delivery start sequence
(`pendingDeliveryStartSeq`) so the renderer reconciles live chunks racing a
restore without misreading foreign sequence domains as duplicates.
6. Terminal query authority is singular and structural: the party that
writes a chunk into a live terminal answers its queries. Visible renderer
and remote views keep xterm authority. Chunks dropped by the
hidden-delivery gate are answered exactly once by the main model
responder, from runtime-emulator state plus renderer-pushed view
attributes. Replayed, seeded, or snapshot bytes are answered by no one.
The daemon emulator never answers. (Amended by Phase 5 — see
[`terminal-query-authority.md`](./terminal-query-authority.md).)
7. The transcript contract stays separate from screen restore. `orca terminal
read` must preserve bounded previews, cursor pagination, partial-line rules,
truncation flags, and total counts even if view snapshots change shape.
8. Local, daemon, SSH, remote runtime, mobile, and CLI paths must either satisfy
the same model/view contract or explicitly report that model recovery is
unavailable.
## Current Owners
| Responsibility | Current owner |
| --- | --- |
| PTY byte source and local/SSH delivery | `src/main/ipc/pty.ts` |
| Hidden-delivery gate (hidden marks, delivery interest, drop accounting, restore markers) | `src/main/ipc/pty-hidden-delivery-gate.ts`, drop sites in `src/main/ipc/pty.ts` and `src/main/ssh/ssh-relay-session.ts` |
| Side-effect parsing and the `pty:sideEffect` facts channel | `src/shared/terminal-output-side-effects.ts` driven from `OrcaRuntimeService.onPtyData`; renderer policy in `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts` |
| Model query responder and view-attribute bridge | `src/main/runtime/terminal-model-query-authority.ts`, `src/main/daemon/terminal-view-attribute-responder.ts`, `src/main/runtime/terminal-view-attribute-store.ts` |
| Hidden view parking policy and parked watcher | `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts`, `parked-terminal-byte-watcher.ts` |
| Daemon PTY state and headless snapshots | `src/main/daemon/headless-emulator.ts` |
| Runtime headless state, retained reads, mobile/session tabs | `src/main/runtime/orca-runtime.ts` |
| Remote terminal subscribe/multiplex/ACK semantics | `src/main/runtime/rpc/methods/terminal.ts` |
| Renderer xterm view and hidden restore behavior | `src/renderer/src/components/terminal-pane/pty-connection.ts` |
| Remote desktop runtime xterm transport | `src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts` |
## Snapshot Contract
A model snapshot must include:
- terminal dimensions used to produce the snapshot;
- enough ANSI state to rehydrate xterm before snapshot content;
- bounded screen and scrollback content;
- title and cwd metadata when known;
- source metadata that distinguishes headless/model snapshots from renderer
fallback snapshots;
- monotonic ordering metadata for live-output reconciliation when available.
A snapshot must not:
- include unbounded transcript history;
- answer terminal queries while replaying into the model;
- overwrite newer live view output with older model output;
- hide that recovery was unavailable for a PTY surface.
## View Contract
A renderer or remote view may:
- write active visible output immediately;
- budget visible inactive output;
- stop receiving hidden output entirely while main's hidden-delivery gate owns
the bytes (model restore on reveal);
- request fresh snapshots for restore, mobile subscription, or explicit remote
snapshot recovery.
A view must:
- keep live-output buffers bounded while a snapshot is in flight;
- apply generation or sequence checks before replaying a snapshot;
- refresh/repaint after replay when xterm/WebGL needs an explicit paint;
- keep side effects such as title, bell, cwd, and agent status flowing from the
PTY/model path (the `pty:sideEffect` facts channel) even while renderer byte
delivery is budgeted, gated, or parked.
## Transcript Contract
The retained read transcript is not a screen dump. It must preserve:
- uncursored bounded latest preview behavior;
- cursor reads over completed retained lines;
- `oldestCursor`, `nextCursor`, `latestCursor`, and `returnedLineCount`;
- partial-line duplication rules;
- `truncated`, `limited`, and total count metadata;
- bounded memory for long partial lines and large output bursts.
Snapshot optimizations must be tested against this transcript contract instead
of assuming xterm scrollback serialization can replace it.
## Required Contract Tests
Before moving more runtime behavior behind the model/view boundary, add or
extend tests that prove:
- headless snapshots rehydrate rich alternate-screen TUI state;
- the daemon emulator never answers DA, DSR, OSC 11, or theme-sensitive
queries (the `session.test.ts` pins are permanent);
- the main runtime responder answers queries only from live chunks the
hidden-delivery gate dropped — never delivered, replayed, seeded, or
remote-subscribed chunks;
- hidden renderer overflow restores from model state without duplicate live
output;
- sleep/wake and worktree revisit restore from model-correct state;
- SSH-backed PTYs follow the same snapshot and ordering semantics as local PTYs;
- remote runtime multiplex output remains ACK bounded and can request recovery
snapshots;
- mobile subscribers receive bounded snapshots without unbounded pending live
output;
- retained terminal reads remain pageable and bounded after large output.
Current coverage is spread across:
- `src/main/daemon/headless-emulator.test.ts`
- `src/main/daemon/session.test.ts`
- `src/main/ipc/pty.test.ts` (hidden-gate drops, restore markers,
`pendingDeliveryStartSeq`)
- `src/main/ipc/pty-hidden-delivery-gate.test.ts`
- `src/main/runtime/mobile-subscribe-integration.test.ts`
- `src/main/runtime/rpc/terminal-subscribe-buffer.test.ts`
- `src/main/runtime/rpc/terminal-multiplex.test.ts`
- `src/main/runtime/orca-runtime.test.ts`
- `src/main/runtime/terminal-query-responder.test.ts`
- `src/shared/terminal-output-side-effects.test.ts`
- `src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts`
- `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts`
- `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts`
- `src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts`
- `src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts`
- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts`
- `tests/e2e/terminal-hidden-view-parking.spec.ts`
- `tests/e2e/terminal-parked-memory.spec.ts`
- `tests/e2e/terminal-sleep-wake-restore.spec.ts`
- `tests/e2e/terminal-output-scheduler.spec.ts`
- `tests/e2e/artificial-opencode-terminal-load.spec.ts`
## Architecture Status
All six phases of the terminal model/view architecture are shipped; the kill
switches noted in parentheses default on:
1. **Hidden view parking** — "Park hidden terminal views behind a byte
watcher": hidden terminal tabs unmount their xterm after a cold-park
hysteresis; a pane-less watcher keeps bell/title/agent/PR side effects
alive while parked (`terminalHiddenViewParking`). See
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md).
2. **Parked memory benchmarks** — "Benchmark parked hidden terminal memory":
renderer heap and live-terminal counts gate parking in the perf suite
(`tests/e2e/terminal-parked-memory.spec.ts`).
3. **Side-effect authority in main** — "Track terminal titles in main with
all-titles ordering", "Move terminal side-effect authority to a main facts
channel", "Complete terminal side-effect facts coverage", "Finish terminal
side-effect authority migration": every local/daemon/SSH PTY byte is
side-effect-parsed once in main and delivered as `pty:sideEffect` facts
(`terminalMainSideEffectAuthority`). See
[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md).
4. **Hidden delivery gate** — "Gate PTY delivery to hidden terminal views":
main drops renderer-bound bytes for hidden-marked PTYs after model
ingestion; delivery-interest registrations exempt sidecar byte consumers,
out-of-band restore markers latch model restore, and
`pendingDeliveryStartSeq` reconciles live output racing a restore
(`terminalHiddenDeliveryGate`).
5. **Model query authority** — "Answer hidden terminal queries from the
model", "Bridge renderer view attributes to the model responder", "Align
query authority contract and spawn-time ownership": hidden-dropped queries
are answered by the runtime emulator plus renderer-pushed view attributes,
and hidden-at-spawn PTYs are marked before byte one
(`terminalModelQueryAuthority`). See
[`terminal-query-authority.md`](./terminal-query-authority.md).
6. **Skip grammar deletion** — "Delete the hidden renderer skip grammar": the
renderer's per-chunk hidden-skip eligibility grammar and the 10s codex
startup query window are deleted; the kill-switch-off fallback is the
bounded background queue with overflow-latched model restore.
Treat every hidden/slept/revisited TUI glitch as a contract failure, not as a
local repaint quirk. Renderer fallback paths retire only when their kill
switches do, and only after the equivalent model path has platform and TUI
golden coverage.
+347
View File
@@ -0,0 +1,347 @@
# Terminal Query Authority
Status: Shipped — Phase 5 of the terminal model/view architecture, kill
switch `terminalModelQueryAuthority` (default on). Builds on
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) (this
phase **amends invariant 6**),
[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md)
(Phase 3), and the Phase-4 hidden-delivery gate
(`src/main/ipc/pty-hidden-delivery-gate.ts`).
## Problem
Phase 4 drops renderer-bound bytes for hidden-gated PTYs after model ingestion
(`src/main/ipc/pty.ts:1426,1515`, `src/main/ssh/ssh-relay-session.ts:931`).
Queries embedded in dropped bytes get no reply: DA1 (ConPTY 1.22+ blocks
waiting for it — `terminal-conpty-device-attributes.ts:22`), CPR probes hang
TUIs, OSC 10/11 leaves `claude /theme` blind while hidden. The pre-Phase-4
hidden skip latch had the same hole (only mode 2031 and the 10s codex startup
window answered), so this is not a regression — it is the long-standing gap
this phase closes.
Contract invariant 6 ("the model must never answer queries") was written
against a real bug: the daemon emulator replying ahead of the renderer with
default-xterm values (the OSC-11 default-black-background race,
`headless-emulator.ts:86-97`, pinned by `session.test.ts:163-190`). The danger
was never "the model answers" — it was **two answerers for the same bytes**,
one of them with wrong values. Phase 5 keeps the singularity and fixes the
values.
## Decision: the delivery decision is the reply decision
Main answers a query **iff main dropped the chunk that carried it**. The same
per-chunk hidden-gate predicate (`shouldDropHiddenRendererPtyData`) that
decides renderer delivery decides reply ownership, evaluated once,
synchronously, at ingestion:
- Visible/unmarked PTY → chunk delivered → renderer xterm auto-replies via
`Terminal.onData``transport.sendInput`, unchanged.
- Hidden-marked, no delivery interest → chunk dropped → main answers from the
runtime headless emulator, via the provider input path (`provider.write`,
same path as `pty:write`; daemon shell-ready write gating and the SSH relay
write apply unchanged).
- Replayed/seeded/snapshot bytes → answered by no one (replay guards on both
sides).
This is structurally exactly-one-responder: a chunk is delivered or dropped,
never both, and each side only answers bytes it actually parsed live. The
mark/unmark ordering, unhide-before-restore, and restore-marker IPC all exist
from Phase 4 and are reused, not duplicated.
Rejected alternatives:
- **Fact-based renderer replies per query class** (the mode-2031 pattern
generalized): needs a main-side detection grammar per query, a fact round
trip per reply, and the renderer cannot answer CPR/DECRPM anyway — the
emulator is the only state for a hidden pane. The 2031 fact stays because it
is subscription registration, not a state query.
- **Emulator always answers**: re-creates the OSC-11 double-reply race for
visible panes. Never.
## Mechanism: forwarded emulator onData, not a new grammar
`HeadlessEmulator` has `onData` wiring behind a per-write capture flag.
For static and model-state queries, xterm core **is** the query grammar: the
runtime emulator runs the same xterm version with equivalent options as the
renderer pane, so main's reply set equals the visible renderer's by
construction — verified empirically against the bundled headless build:
DA1/DA2, DSR 5n, CPR, DECRPM (including unknown-mode `0`), DECRQSS (including
DECSCUSR from cursor options), XTVERSION, kitty `CSI ? u` all reply; XTWINOPS
(`windowOptions` stays default-off) and XTGETTCAP stay silent, matching
visible behavior today. The headless build has **no theme service**: OSC
4/10/11/12 queries and DSR ?996n return nothing even with the `theme` option
set, so the view-attribute class is answered by responder-registered parser
handlers instead (below) — never by core defaults.
Forwarding predicate, captured per chunk in `OrcaRuntimeService.onPtyData` and
attached to the emulator `writeChain` link (the mark can flip between
ingestion and an async write; the decision must not be re-read at reply time):
1. gate enabled (`terminalMainSideEffectAuthority` and
`terminalHiddenDeliveryGate` both on) AND new kill switch
`terminalModelQueryAuthority !== false`;
2. the chunk was hidden-dropped for this PTY (`shouldDropHiddenRendererPtyData`
— same module state, same tick as the drop sites);
3. the write is live PTY data — never `seedHeadlessTerminal`,
`maybeHydrateHeadlessFromRenderer`, option pushes, or any snapshot replay
(main-side replay guard, mirror of the renderer's `replay-guard.ts`);
4. no remote view subscriber is attached to the PTY (runtime terminal-RPC
subscriber records / `mobileSubscribers`): a mobile/web/remote-desktop
xterm receiving the multiplexed stream answers with view authority, exactly
like a visible local pane. Legacy JSON `terminal.subscribe` streams **do**
register as view subscribers and suppress, even when the consumer is a
read-only watcher — deliberately conservative, because the stream may feed
an older live xterm view and a withheld reply (the pre-Phase-5 status quo)
is strictly safer than a double reply. Consumers that never register a
stream (CLI `terminal.read`, automation observers) do not suppress — they
also do not answer; that bounded no-reply case matches today's behavior.
Mobile streams preserve that singularity across snapshot startup and multiple
views. The mobile WebView suppresses `onData` during snapshot replay, then
enables replies at an explicit live-output boundary. Clearing the WebView drops
that replay queue and resumes live reply authority immediately. If a live query
arrived while the initial snapshot was being built and its output sequence is
covered by that snapshot, runtime RPC re-emits only the bounded query sequence
after the snapshot; ordinary covered output stays deduplicated. `terminal.send`
accepts the resulting `inputKind: query-reply` only from the earliest active
phone-fitted mobile subscriber while mobile owns the terminal driver. Earliest
is determined by preserved `subscribedAt`, so a soft-leave resubscribe does not
change reply ownership; passive desktop-mode watchers are excluded. Peer phones
are rejected, the next subscriber is promoted on unsubscribe, and desktop
parser and capability handlers stay silent until desktop retakes the driver.
Mixed versions: hosts advertise `terminal.query-reply-input.v1`; a mobile
client never forwards replies to a host that lacks it, because such hosts
strip `inputKind` and would treat the bytes as floor-taking shell input.
Residual: a desktop→mobile driver handoff has a bounded double-reply window
until the async driver-change event reaches the desktop renderer's cached
driver map (`isPtyLocked`).
Everything the emulator emits outside a forwarding window is discarded, which
also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes
triggered by option mutations).
## Reply classes
| Class | Queries | Answer source |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state |
| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative |
| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** |
| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options |
| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden |
| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard |
### View-attribute bridge
Renderer→main push, `pty:terminalViewAttributes` — one global snapshot,
not per-PTY: the composed terminal `ITheme` (from
`applyTerminalAppearance`, `terminal-appearance.ts`),
`terminalCursorStyle`, `terminalCursorBlink`, and the resolved color-scheme
mode (`resolveTerminalColorSchemeMode` — the same source as the existing
hidden 2031 reply). Pushed on renderer startup and on every theme/settings
apply.
Main consumes it two ways:
- `cursorStyle`/`cursorBlink` are applied to every runtime emulator's options
inside the replay guard; xterm core then answers DECRQSS DECSCUSR and
DECRQM 12 with renderer-true values (verified working headless).
- Palette and color-scheme replies come from responder-registered parser
handlers on the emulator (`registerOscHandler` 4/10/11/12,
`registerCsiHandler` for DSR ?996n), because the headless core cannot
answer them. The OSC handlers see SET payloads too, so runtime OSC
4/10/11/12 mutations (and 104/110/111/112 resets) from the byte stream are
tracked per PTY and layered over the pushed base palette — matching what
the renderer's theme service reports for a visible pane.
Staleness rules: replies use the last push; a theme flip is stale for at most
one IPC hop (subscribed TUIs are corrected by the 2031/997 flip push).
**Before the first push main answers no view-attribute query** — a fabricated
default would resurrect the default-black OSC-11 bug; silence is the
documented hidden status quo.
### Kitty keyboard flags
`vtExtensions.kittyKeyboard: true` is enabled in `HeadlessEmulator`, matching
`buildDefaultTerminalOptions` (`pane-terminal-options.ts:50`). Risk is low:
for the write-only daemon use, keyboard state never alters serialization; the
change only makes the emulator parse `CSI =/>/< u` pushes instead of ignoring
them, and lets the responder answer `CSI ? u` with the flags the hidden app
actually pushed. Snapshot parity: add `kittyKeyboardFlags` to `TerminalModes`
for emulator re-seed parity only. `rehydrateSequences` must **not** push kitty
flags into a renderer xterm — `POST_REPLAY_REATTACH_RESET`'s deliberate kitty
reset (stale CSI-u Ctrl+C hazard, `terminal-replay-cursor-state.test.ts`)
stays authoritative. Slice 3 wires the re-seed consumer: the daemon
warm-reattach snapshot threads `modes.kittyKeyboardFlags` through the spawn
result into `seedHeadlessTerminal`, which applies them to the fresh runtime
emulator via its own `CSI = flags ; 1 u` parse (outside any forwarding
window), so hidden `CSI ? u` reports the flags the hidden app actually
pushed. Paths without a snapshot (cold restore spawns a fresh shell) answer
`?0u`; protocol-conformant programs re-push.
### ConPTY DA1 variant
The provider kind is known main-side: mirror `isLocalNativeWindowsPty`
(`windows-pty-compatibility.ts:59`) from the spawn record (local/daemon
provider, `win32`, not WSL). For such PTYs register a CSI `c` override on the
emulator parser (the main-side twin of
`installConptyDeviceAttributesHandler`) replying `CSI ?61;4c`, still gated by
the forwarding predicate. The override is installed at emulator creation and
retrofitted when the spawn mark lands (daemon stream data can create the
emulator before the awaited spawn response marks the PTY). ConPTY blocking on
a missing DA1 is a spawn-time hazard; the hidden-at-spawn loss window is
closed by the slice-3 `initiallyHidden` spawn flag (races section).
## Suppression: when main never replies
- Visible or unmarked PTY (chunk was delivered).
- Renderer delivery interest registered (chunk was delivered to a sidecar).
- Remote-runtime (`remote:`) PTYs — never markable
(`isHiddenDeliveryGateManagedPty`), bytes never transit local main.
- Remote view subscriber attached (mobile/web/remote desktop owns replies).
- Seed/hydration/snapshot writes into the emulator, and option pushes.
- Kill switches off — no marks exist, and `terminalModelQueryAuthority` is an
independent off switch for the responder alone.
- The **daemon** emulator: never, under any setting. The responder lives in
main's runtime only; `session.test.ts:163-190` stays pinned verbatim.
## Transition races
Worst cases, per direction:
- **visible→hidden**: chunks delivered between the visibility flip and the
mark landing in main are hidden-skipped by the renderer write path without
query scanning. No reply, no duplicate — identical to the pre-Phase-4 hidden
skip behavior, bounded by one renderer→main IPC hop. After the mark lands,
main answers everything it drops.
- **hidden→visible**: unmark consumes the drop latch and emits the restore
marker; the snapshot replay is replay-guarded, so queries main already
answered are never re-answered from the snapshot; post-unmark live chunks
are answered by xterm once (restore-queued live chunks reply late, not
twice).
- **Split queries across the drop/deliver boundary**: neither parser saw the
whole sequence → no reply; the restore marker resets renderer cross-chunk
state and replay hygiene resets the parser. At-most-once holds.
Safe-side rule per class: duplicates are structurally impossible (one decision
point per chunk); where the race costs anything it costs a missing reply.
That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or
tolerate silence, as they did for every hidden pane before this phase). The
one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible
pane answers it from the renderer xterm. A PTY spawned hidden previously had
no answerer until the renderer's hidden mark landed in main (one IPC hop
after spawn). Slice 3 closes that window with the `initiallyHidden`
spawn-record flag: the renderer declares hidden-at-spawn on `pty:spawn`
(never for remote-runtime transports), and main marks the PTY hidden before
the first byte — pre-spawn for
daemon-host sessions whose id is minted up front, immediately after
`provider.spawn` resolves otherwise — so the gate and responder own queries
from byte one. The pane's first visibility sync then re-marks or unmarks
through the existing Phase-4 machinery (unmark emits the restore marker for
any spawn-window drops).
## Invariants
1. Exactly one party may answer any query, chosen by the chunk's delivery
decision: delivered → the consuming live view's xterm; dropped → main's
model responder; replayed/seeded → no one. The decision is captured once,
synchronously, at ingestion.
2. Main answers only from live PTY bytes parsed by the runtime emulator —
never from snapshot, seed, hydration, or option-push writes.
3. View-attribute answers are renderer-true or absent: no reply is ever
fabricated from emulator defaults (the OSC-11 lesson).
4. The daemon emulator stays write-only; daemon subprocess query writes stay
zero (`session.test.ts` pins are permanent).
5. Reply parity is structural for static and model-state classes: same xterm
core, equivalent options, no hand-rolled grammar — the only overrides are
the documented ConPTY DA1 variant and the view-attribute parser handlers
the headless core cannot serve.
6. Remote views keep view authority; main yields whenever a remote view
subscriber is attached. When multiple desktop/mobile views coexist, the
terminal driver and server-side mobile election keep one reply writer.
**Contract amendment**`terminal-model-view-contract.md` invariant 6 is
replaced by:
> 6. Terminal query authority is singular and structural: the party that
> writes a chunk into a live terminal answers its queries. Visible renderer
> and remote views keep xterm authority. Chunks dropped by the
> hidden-delivery gate are answered exactly once by the main model
> responder, from runtime-emulator state plus renderer-pushed view
> attributes. Replayed, seeded, or snapshot bytes are answered by no one.
> The daemon emulator never answers.
The contract's test bullet "headless tracking does not answer DA, DSR, OSC 11,
or theme-sensitive queries" splits into: daemon emulator never answers
(unchanged pins) / runtime responder answers only hidden-dropped chunks. The
side-effect authority matrix row "DECSET 2031 reply — query authority stays
with the view (contract invariant 6)" gains a pointer here; its reply path is
otherwise untouched in this phase.
## Test strategy
- Responder unit tests beside `orca-runtime.test.ts`: marked vs unmarked vs
interest-suppressed; each reply class; seed/hydrate silence; remote-
subscriber suppression; ConPTY DA1 variant; kill-switch off; mark flip
between ingestion and async emulator write (captured decision wins).
- Parity harness: shared query byte fixtures through a renderer-configured
xterm (onData capture) and through the responder; assert byte-identical
replies for static + model-state classes, and for view-attribute classes
after an attribute push.
- `session.test.ts:163-190`: assertions stay; the comment is updated to name
the main responder (not "the renderer") as the hidden answerer.
- E2E: hidden `claude /theme` reports the configured theme; hidden TUI
blocked on CPR/DA unblocks while gated; reveal shows no stray reply
fragments (`?1;2c`, `rgb:` …) on the prompt; Windows ConPTY golden and
`terminal-hidden-view-parking.spec.ts` stay green.
## Cut-offs (shipped as three stacked slices)
1. **Responder core.** Emulator onData wiring + per-write capture + main
replay guard; kitty flag enable (+ `TerminalModes.kittyKeyboardFlags`);
static + model-state classes; ConPTY DA1 override; remote-subscriber
suppression; `terminalModelQueryAuthority` switch; unit + parity tests.
Main-only — no renderer change. Ships the DA1/CPR/DECRPM unblock.
2. **View-attribute bridge.** `pty:terminalViewAttributes` push, cursor
option application under the guard, responder OSC/DSR parser handlers with
per-PTY palette-mutation tracking, silent-until-push rule, `/theme` e2e.
3. **Contract alignment.** Invariant-6 amendment in the contract doc, test
bullet split, `session.test.ts` comment, side-effect matrix pointer, and
the Phase 6 prerequisites below recorded as accepted.
## Phase 6 (delete skip grammar + startup window): prerequisites from this design
Phase 6 is shipped: the renderer hidden-skip eligibility grammar and the 10s
codex startup renderer-query window are deleted. Kill-switch-off hidden panes
fall back to the pre-grammar path — hidden bytes ride the bounded background
scheduler queue; overflow latches the model-snapshot restore — and never run
a per-chunk content scan.
Accepted and shipped in slice 3 (except where noted):
- **Mark-before-first-byte** (shipped): panes spawned without a visible view
are hidden-marked at spawn via the `initiallyHidden` flag on `pty:spawn`
(spawn-record flag, not a renderer round trip) so startup queries —
including ConPTY's blocking DA1 — are main-owned from byte zero. Phase 6
removed the codex exclusion with the window: codex spawns are main-owned
from byte zero too, the responder answering their startup probes.
- **Attributes before spawn** (shipped): the renderer pushes composed view
attributes once at app start (right after settings load, before terminal
reconnect/spawn), so spawn-time view-attribute queries no longer fall into
the silent-until-push rule. Per-pane appearance applies keep re-publishing
through the same deduped publisher.
- **Daemon shell-ready write gating** (verified): responder replies through
`ptyController.write` → daemon `Session.write` are QUEUED pre-ready, never
dropped, and the queue flushes at the shell-ready marker or the 15s
`SHELL_READY_TIMEOUT_MS` bound (`session.ts`). The codex window was removed
with hosted ConPTY golden coverage, unit DA1 parity, and the kill switches
as the safety net; explicit spawn-time e2e on Windows daemon PTYs remains
worth adding.
- With the skip grammar deleted, every chunk is either written to a live
xterm or dropped — the delivered-but-skipped no-reply gap disappears and
the only remaining loss window is the mark IPC race.
- **2031 consolidation** (optional follow-up): move the subscription registry
into the responder (the headless core cannot serve 997 pushes any more than
it can ?996n) and push 997 flips from the attribute cache, retiring the
`2031-subscribe` fact reply, the parked responder, and the parked-tab
theme-flip gap.
@@ -0,0 +1,252 @@
# Terminal Side-Effect Authority
Status: Shipped — Phase 3 of the terminal model/view architecture, kill switch
`terminalMainSideEffectAuthority` (default on). Builds on
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) and
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) (Phase 1).
## Problem
Main parses every local/daemon/SSH PTY byte before renderer delivery
(`OrcaRuntimeService.onPtyData` in `src/main/runtime/orca-runtime.ts`:
side-effect tracker, OSC 9999 agent status, headless emulator, tails, URL
watchers; SSH feeds the same path from `wireUpPtyEvents` in
`src/main/ssh/ssh-relay-session.ts`). Before this phase, the side effects
users see — bell unread/notifications, title transitions, agent-complete
notifications, command lifecycle, PR links — were derived a second time by
renderer byte parsers. That duplication forced Phase 1's watcher to parse
bytes, forced main to fabricate synthetic OSC title frames over `pty:data`
just so renderer parsers could see them, and blocked Phase 4 from ever
stopping hidden byte delivery. Phase 3 made main the side-effect parser for
every PTY whose bytes transit local main; the renderer byte parsers
(`createPtyOutputProcessor` in `pty-transport.ts`, the parked watcher's byte
mode) survive only for remote-runtime PTYs and the kill-switch-off fallback.
## Authority Matrix
"Main" means parsed once in `onPtyData` and delivered as derived facts.
Remote-runtime PTYs (`remote:`) never transit local main; the renderer
(`remote-runtime-pty-transport.ts:74`) stays their parser permanently.
| Side effect | local-daemon | SSH | remote-runtime |
| --- | --- | --- | --- |
| OSC 9999 agent status | main (parsed in `onPtyData`, emitted as `agentStatus:set`) | main | renderer (`shouldOwnAgentStatusInRenderer`, `pty-connection.ts`) |
| OSC 0/1/2 titles + working/idle/exited tracker + 3s stale-title timer | main | main | renderer |
| BEL attention (OSC-aware stateful detector) | main | main | renderer |
| OSC 133;D command-finished exit code | main | main | renderer |
| GitHub PR-link scan | main | main | renderer |
| Command Code output scrape | main (per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main | renderer |
| DECSET 2031 color-scheme reply | renderer view/watcher — the 2031 fact reply path is untouched by Phase 5; general query authority is now per-chunk structural ownership, see [`terminal-query-authority.md`](./terminal-query-authority.md) (contract invariant 6 as amended) | same | renderer |
| DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer |
## Main-Side Tracker
- The side-effect core shared with the renderer processor lives in
`src/shared/terminal-output-side-effects.ts`: all-titles ordering via
`extractAllOscTitles` (coalesced working→idle transitions are why last-title
is insufficient — issue #1083), `normalizeTerminalTitle`, the literal
`cursor agent` title drop (`CURSOR_NATIVE_TITLE_LOWER`,
`src/shared/agent-detection.ts`), the `createAgentStatusTracker`
transitions, the stale-working-title 3s timer
(`STALE_WORKING_TITLE_TIMEOUT_MS`), and the stateful BEL detector
(`src/shared/terminal-bell-detector.ts`).
- One tracker per PTY on `OrcaRuntimeService`, lazily created like
`agentStatusOscProcessorsByPtyId`; disposed in `onPtyExit` (cancels the
stale-title timer).
- It replaced the chunk-level last-title extraction in `onPtyData`: titles
feed in byte order, so `lastOscTitle`/`lastAgentStatus`, tui-idle waiters,
and pending-message delivery see intermediate transitions instead of only
the chunk's last title. PTY/leaf records keep the **raw** last title
(worktree `ps` and mobile tab titles expect raw); emitted facts carry
`(normalizedTitle, rawTitle)` like `onTitleChange`.
- No deferred drain in main — the renderer's setTimeout(0) batching
(`sideEffectDrainTimer`, `pty-transport.ts`) protects xterm paint, which
does not exist in main. Main applies synchronously and batches the IPC per
flush.
- The stats `AgentDetector` (`src/main/stats/agent-detector.ts`) keeps its own
last-title scan, untouched: synthetic titles must never reach it.
## Event Transport: `pty:sideEffect`
One batched main→renderer channel (`window.api.pty.onSideEffect`,
`src/preload/index.ts`). It is **not** routed through the pty dispatcher:
the renderer fact-consumer registry
(`terminal-side-effect-facts-handler.ts`) subscribes directly via
`window.api.pty.onSideEffect` — one channel subscription per renderer, with
exactly one registered fact consumer per PTY. Events are **facts, not
decisions**: `title`, `bell`, `agent-working`, `agent-idle` (with title),
`agent-exited`, `command-finished` (exit code), `pr-link`. Each carries
`ptyId`, main-known attribution (worktreeId/tabId/paneKey from runtime leaf
records, same resolution as `emitTerminalAgentStatusEvents`), and the PTY
`outputSequence`.
Ordering rules:
1. Per-PTY in-order; facts from one chunk are emitted in byte order (status
payloads, then titles in sequence, then bell — the renderer drain's order).
2. Deliberately **not** synchronized with `pty:data`: side effects must keep
advancing while renderer delivery is ACK-gated (contract invariant 1). A
completion title may reach the store before the visible xterm paints the
final output; that is acceptable — attention/title state is out-of-band UI
state, and today's renderer drain already decouples by many batches under
timer throttling.
3. No attention replay: facts emitted while no renderer is subscribed are
dropped. On transport attach/park-handoff the renderer pulls a title-only
snapshot (`pty:sideEffectSnapshot`) marked `replay: true` — this reproduces
the eager-buffer behavior where replay restores titles but is barred from
bells/completions (`suppressAttentionEvents`, `pty-transport.ts`). The
store handler ignores a replay title older (by `outputSequence`) than the
last live title fact it applied.
## Renderer Store Handler (policy stays in the renderer)
Notification semantics, all preserved across the authority flip:
- BEL marks worktree+tab unread unconditionally — including the focused pane
(`onBell`, `pty-connection.ts`); pane unread only behind
`experimentalTerminalAttention`; keydown clears unread
(`onTerminalKeyDown`, `pty-connection.ts`).
- BEL's OS notification is delayed 250 ms and yields to a pending
agent-task-complete (`scheduleTerminalBellNotification`,
`pty-connection.ts`).
- working→idle starts the Claude cache timer (null settings = not hydrated,
treat enabled) and schedules completion with 250 ms grace + 1500 ms max
wait + detail-wait store subscription
(`AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS` /
`AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS`,
`agent-task-complete-policy.ts`).
- Completion unread is suppressed only for the exact visible foreground pane
(`isVisibleForegroundPaneKey`, `use-notification-dispatch.ts`); BEL unread
has no such check.
- Dispatch-time liveness/staleness guards
(`dispatchTerminalNotification`, `use-notification-dispatch.ts`) and main's
5 s per-worktree cooldown (`NOTIFICATION_COOLDOWN_MS`,
`src/main/ipc/notifications.ts`) remain the final gates.
These need live renderer store state (PTY/layout maps, pane visibility,
settings, `agentStatusByPaneKey`, repo labels), so they stay in the renderer:
the pane-independent per-paneKey handler module
(`terminal-side-effect-facts-handler.ts`) consumes `pty:sideEffect` and
subsumes both `pty-connection.ts`'s callbacks and the parked watcher's
callback block (`sideEffectCallbacks`, `parked-terminal-byte-watcher.ts`) —
one policy path whether the tab is mounted, hidden, or parked. Main holds
**no** notification timers; only the stale-title timer (parser state) lives
in main.
## Synthetic Frame Reroute
`driveSyntheticTitleFromHook` and the spinner tick (`sendSyntheticTitle`,
`src/main/index.ts`) feed `runtime.ingestSyntheticTitleFrame(ptyId, data)`,
so synthetic agent-title/BEL frames enter the per-PTY tracker directly —
**not** `onPtyData`, so emulator state, tails, transcripts, and stats never
see them. The decorative-frame visibility gating
(`shouldSendSyntheticTitleFrame`) stands. The legacy synthetic `pty:data`
copy survives only in kill-switch-off mode, where renderer parsers still
need the bytes. The visible xterm renders nothing from titles, but
`pane.terminal.onTitleChange` feeds `registerPtyTitleSource`
(`pty-connection.ts`) → renderer serialize-snapshot `lastTitle` (mobile
parity); main prefers its own tracked title over renderer snapshot
`lastTitle` in both serialize paths. Under main authority synthetic frames
no longer produce phantom ACKs for bytes main never metered (`ackPtyData`,
`pty-dispatcher.ts`).
## Migration Switch and Double-Fire Prevention
Authority is structural per PTY kind — the predicate is "bytes transit local
main", exactly the `shouldOwnAgentStatusInRenderer` split
(`pty-connection.ts`). One renderer-consulted kill switch
(`settings.terminalMainSideEffectAuthority`, default on, mirroring
`terminalHiddenViewParking`): when on, IPC transports and the parked watcher
do not register byte parsers for local/SSH and the store handler consumes
`pty:sideEffect`; when off, renderer parsers register and `pty:sideEffect`
events are ignored. Main always parses and emits (its internal consumers need
the tracker regardless); main consults the same setting only to keep the
legacy synthetic-frame `pty:data` path alive while the switch is off. Exactly
one consumer per fact at any time — decided at transport/watcher creation, so
no per-chunk race.
## Sidecar Consumers and Phase 4
Keep renderer byte access (input pacing / raw-output consumers, not side
effects): `agent-paste-draft.ts` (DECSET 2004 readiness),
`launch-agent-background-session.ts` (startup-injection pacing, onData
passthrough), `automation-session-observer.ts` (onData passthrough), and
`parked-terminal-mode2031-responder.ts` (DECSET 2031 theme replies for
parked tabs while the delivery gate is off). Their duplicated local OSC 9999
store writes are gated off under main authority (the `onAgentStatus`
automation callbacks still fire; only the racing `setAgentStatus` store
writes drop). The Phase-4 hidden-delivery gate exempts PTYs with an active
`subscribeToPtyData` sidecar: registration is auto-surfaced to main as a
ref-counted delivery-interest signal (`pty-delivery-interest.ts`). With main
authoritative, the parked watcher is purely fact-driven: byte parsing exists
only in kill-switch-off mode, and the 2031 reply comes from the
`2031-subscribe` fact when the gate is on (the byte responder sidecar only
when it is off). The watcher file is deleted outright only when the kill
switch retires — it returns as a byte parser only if remote-runtime tabs
ever become parkable.
## Invariants
1. Every byte is side-effect-parsed exactly once, by exactly one authority,
chosen structurally per PTY kind.
2. Attention facts never replay: snapshot/eager/attach replays restore title
state only.
3. Notification policy (grace timers, yielding, suppression, dispatch guards)
lives with the renderer store; main emits facts with ordering metadata.
4. Side-effect facts keep flowing while renderer byte delivery is
backpressured, parked, or stopped by the hidden-delivery gate.
5. Synthetic agent frames feed the model tracker, never the emulator, tails,
transcripts, or stats.
## Test Strategy
- Parity harness (`terminal-title-tracker-parity.test.ts`): shared byte
fixtures (agent title cycles incl. coalesced chunks, BEL inside/spanning
OSC, CAN/SUB cancellation, cursor-agent literal, stale-title timeout under
fake timers, OSC 133;D, split PR URLs) run through the renderer
`createPtyOutputProcessor` and the main tracker; assert identical ordered
fact sequences.
- Unit: main tracker tests beside `orca-runtime.test.ts` (lastOscTitle
parity, tui-idle waiter transitions, synthetic ingestion); store-handler
tests reusing `parked-terminal-byte-watcher.test.ts` scenarios.
- Pinned tests that flip or retire: `pty-connection.test.ts` callback wiring,
`parked-terminal-byte-watcher.test.ts` (retires with the watcher);
`pty-transport*.test.ts` stay (processor remains for remote + kill switch).
- E2E gates that must stay green throughout: `terminal-attention.spec.ts`,
`droid-notification.spec.ts`, `terminal-hidden-view-parking.spec.ts`,
`terminal-parked-memory.spec.ts`; add main-authority bell/completion cases
(parked tab, focused-pane suppression, kill switch off). SSH parity is
exercised manually per the SSH test procedure before each slice ships.
## Cut-Offs (shipped as four stacked slices)
1. **Shared tracker in main.** Extract the processor core to shared, run the
per-PTY tracker in `onPtyData` replacing `extractLastOscTitle`, parity
tests. Main-internal consumers only; no IPC or renderer change.
2. **Authority flip.** `pty:sideEffect` channel, renderer store handler,
titles/bell/tracker authority to main for local+SSH behind the kill
switch; parked watcher stops byte parsing for those kinds.
3. **Inversion unwind.** Synthetic frames into the tracker, off `pty:data`;
OSC 133;D and PR-link facts; mobile `lastTitle` source preference.
4. **Long tail.** Command Code scrape to main, sidecar OSC 9999 dedup, parked
watcher shrunk to fact-driven mode (deletion waits on kill-switch
retirement), Phase 4 delivery-interest registration documented in the gate
design.
## Open Items
- **Daemon checkpoint `lastTitle` is write-only.** The daemon sleep/periodic
checkpoint (`daemon-pty-adapter.checkpointSessions` → daemon
`Session.getSnapshot`) persists the daemon emulator's `lastTitle`, which is
derived from real PTY bytes only — synthetic hook title frames never reach
the daemon process, so that field cannot carry hook-driven titles. Today no
restore path reads it back (`ColdRestoreInfo` drops it; reattach snapshots
surface only the ANSI payload), so there is nothing to fix. Main-side
consumers of the renderer serializer's `lastTitle` (mobile snapshot reads
and the headless hydration seed) prefer main's tracked title. If a future
consumer starts reading checkpoint `lastTitle`, it must route through the
same tracked-title preference.
- **Kill-switch retirement.** Once `terminalMainSideEffectAuthority` is
removed, the parked watcher's byte-parser mode, the renderer transport
parsers for local/SSH, and the legacy synthetic-frame `pty:data` copy all
become dead code and the watcher byte path can be deleted outright.
@@ -0,0 +1,362 @@
# Terminal Switch Typing Lag Investigation
Date: 2026-07-01
## Scope
This note tracks the investigation into terminal input lag that appears after switching workspaces or terminals in a heavy packaged Orca profile.
The user-visible symptom is that typing after switching back to a workspace can feel delayed for about one second. Text may appear all at once after the delay. The issue reproduced in the user's main packaged Orca app, but not reliably in lighter dev profiles.
## Current Reproduction Setup
- Main packaged Orca app is used for the meaningful repro.
- The main profile had roughly 150 terminals when the lag reproduced.
- The current worktree under test is `/Users/jinwoohong/orca/workspaces/orca/osprey`.
- The main app did not expose CDP, so browser-level renderer profiling was limited.
- The harness creates a throwaway bash terminal, switches away and back, sends a marker command, waits for a receipt file, and closes the throwaway terminal.
- Harness path: `.tmp/terminal-main-app-typing-lag/probe.mjs`
- The strongest repro switches through an old, output-rich workspace before typing in the probe terminal.
- Current deterministic alternate terminal:
`term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408` in the `triage-issues` workspace.
Useful harness modes:
```sh
ORCA_PROBE_RUNS=8 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=terminal-send ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs
ORCA_PROBE_RUNS=8 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs
ORCA_PROBE_RUNS=8 ORCA_PROBE_SKIP_SWITCH=1 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs
ORCA_PROBE_RUNS=4 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_SKIP_FOCUS=1 ORCA_PROBE_SAMPLE_RENDERER=0 ORCA_PROBE_ALT_TERMINAL=term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs
```
## Key Measurements
### 2026-07-01 follow-up: cold visit vs warm revisit
After the metadata-only `listSessions()` fix, the user reported a sharper
pattern:
- First visit to a terminal, where the terminal briefly shows blank while the
renderer/webgl surface loads, does not show the typing lag.
- Revisiting that same already-mounted terminal brings the lag back.
Code-level reproduction:
- `connectPanePty` previously armed a one-shot input liveness check from
`noteVisibilityResume()`.
- The first `xterm.onData` input after a warm visibility resume called
`window.api.pty.listSessions()` before forwarding the byte with
`transport.sendInput(data)`.
- A focused unit regression captured the bad order as
`["listSessions", "sendInput"]`.
Fix direction:
- Terminal input no longer starts a renderer→main→daemon session enumeration.
- Hidden→visible lifecycle reconciliation and daemon missing-session exit events
remain responsible for stale pane cleanup.
- The focused regression now asserts that repeated warm resumes plus typing
produce zero `listSessions()` calls from the input handler.
### 2026-07-01 follow-up: preserved old daemon after input fix
After the input-handler fix, the main packaged profile still reproduced the
warm-switch lag because the live v18 daemon was preserved from an older build:
- Fresh main-profile repro:
`.tmp/terminal-main-app-typing-lag/result-2026-07-01T09-59-08-111Z.json`
- Direct daemon writes after switching took 661, 982, 998, 980, and 1000 ms.
- Receipt latency after the daemon write returned stayed low at 77-85 ms.
- No-switch control:
`.tmp/terminal-main-app-typing-lag/result-2026-07-01T09-59-28-346Z.json`
ended with fast direct daemon writes once the probe terminal settled.
The remaining source hot path was the visibility-resume dead-session sweep:
1. Switching a warm terminal hidden→visible ran the lifecycle visibility effect.
2. The effect scheduled `reconcileDeadSessions`.
3. `reconcileDeadSessions` invoked `window.api.pty.listSessions()`.
4. A preserved old daemon still implemented `listSessions` by snapshotting every
live session, so the user's next daemon `write` queued behind that work.
Fix:
- Replace the automatic visibility-resume `listSessions()` sweep with a targeted
single-session liveness check.
- Main exposes `pty:hasPty(id)`, which reads provider-owned in-memory PTY state
and returns `null` when the provider cannot answer authoritatively.
- Renderer visible-resume asks only about each mounted pane's current PTY id.
The pane tears down only on an authoritative `false`; `true`, `null`, rejected
checks, remote-runtime ids, SSH ids, and stale/newborn races all fail open.
- Keep visibility resume process tracking and PTY-size reassertion intact.
- This preserves the recovery added by `a9ef6f916` for panes that missed
`pty:exit` while hidden, without putting a daemon-wide session enumeration on
the warm-switch/input path.
Verification:
- Focused vitest suite: `508` tests passed across terminal lifecycle,
pty-connection, dead-session reconcile, and PTY IPC.
- Headful fullscreen E2E harness:
`tests/e2e/terminal-warm-switch-no-list-sessions.tmp.spec.ts`
wraps main-process `pty:listSessions` with an 800 ms stall and then switches
warm workspaces and types into the terminal.
- Latest E2E artifact:
`.tmp/terminal-warm-switch-no-list-sessions/result-1782929853828.json`
showed `fullscreen: true`, `listSessionCallCount: 0`, and
`postTypeEchoLatencyMs: 10`.
### CLI `terminal-send`, switch away/back
Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T06-59-45-098Z.json`
- Average echo latency was about 1841 ms.
- Receipt latency was roughly 836-1445 ms.
### CLI `terminal-send`, no switch
- Average echo latency was about 312 ms.
### CLI `terminal-send`, switch away/back, 1000 ms settle before send
- Average echo latency was about 574 ms.
- Receipt latency was mostly 1-227 ms, with one run around 684 ms.
- This suggests the problematic window is bounded and concentrated immediately after switch/resume.
### Direct daemon request, switch away/back
Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-04-43-497Z.json`
- The harness opened its own daemon control socket and sent the real daemon `write` request directly.
- Daemon write response time was roughly 947-1142 ms.
- Receipt latency after the daemon write response was about 76 ms.
- Average echo latency was about 1385 ms.
### Direct daemon request, no switch
Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-05-12-496Z.json`
- Daemon write response time was 0-45 ms.
- Receipt latency was roughly 76-85 ms.
- Average echo latency was about 258 ms.
### Direct daemon request, output-rich workspace switch
Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-22-06-789Z.json`
- The alternate terminal was `term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408` in the `triage-issues` workspace.
- All four runs delayed.
- Direct daemon `write` response times were 1566, 1527, 1477, and 1333 ms.
- Average echo latency was about 1790 ms.
- A daemon CPU sample was captured at `.tmp/terminal-main-app-typing-lag/daemon-71111-sample-20260701032206.txt`.
Latest reproduced run:
`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-27-25-513Z.json`
- Two runs after switching through `triage-issues`.
- Direct daemon `write` response times were 1556 and 1582 ms.
- Receipt latency after the daemon write response was about 76-77 ms.
- This confirms the daemon request itself waits; once it returns, the PTY and shell process the input quickly.
### Direct daemon ping loop during switch
A direct daemon client sent `ping` requests every roughly 50 ms while the harness issued `orca terminal switch` away and back.
- Pings before and after the switch were effectively immediate.
- One ping sent around 469 ms after switch start waited 1212 ms.
- No terminal input was involved in this probe.
This is the strongest evidence so far that workspace/terminal switching creates a daemon event-loop stall. The typing lag is a user-visible symptom of the same stall, not the root trigger.
### Direct daemon request, no switch
Latest no-switch result:
`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-27-58-622Z.json`
- First run had a direct daemon `write` response of 571 ms, likely a new-probe/settling artifact.
- Next two runs were 0-1 ms.
- No-switch is generally fast once the probe terminal is settled.
### Pause after switching away
- Adding `ORCA_PROBE_AFTER_SWITCH_AWAY_MS=1500` before switching back made direct daemon writes fast again.
- This indicates expensive work starts when Orca switches into or resumes the output-rich workspace, then spills into the immediate switch-back/write window.
Latest pause-control result:
`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-29-58-568Z.json`
- Direct daemon `write` response times were 0, 0, and 0 ms.
- Echo latency was about 343-363 ms.
- This bounds the problematic window to roughly the first 1-1.5 seconds after switching into/through the expensive workspace.
### Synthetic heavy terminals
- Synthetic split panes with large scrollback were not enough to reproduce reliably.
- A plain heavy scrollback split reproduced once in four runs, then larger synthetic scrollback stayed fast.
- A synthetic TUI repaint split stayed fast.
- The issue is therefore not simply "large output" or "any hidden repaint"; old retained/reattach/snapshot state is still suspect.
### Output timestamp check
- `lastOutputAt` did not advance when switching through the `triage-issues` terminal and away.
- That weakens the theory that a resume-triggered SIGWINCH caused the child TUI to emit a fresh repaint burst.
- The delay can happen without fresh PTY output from the child process.
### Snapshot and resize controls
Direct `getSnapshot` probes on real sessions were much cheaper than the observed lag:
- Output-rich `triage` terminal: about 10 ms, roughly 122 KB response.
- Active `osprey` Codex terminal: about 57 ms, roughly 804 KB response.
Synthetic resize pulses on throwaway heavy-scrollback terminals were also cheap:
- Background 80x24 resize pulse: about 2 ms.
- Focused 232x86 resize pulse: about 2 ms.
These controls weaken the idea that one ordinary snapshot or one ordinary resize explains a 1.2-1.6 second stall. The remaining suspicious shape is switch-time fanout: many warm reattachments, snapshots, visibility/resume requests, pending-output drains, or checkpoint-like work running serially in the daemon.
### Daemon `listSessions` proof
The daemon-only measurement identified the blocking request:
```json
[
{ "type": "ping", "elapsedMs": 0 },
{ "type": "listSessions", "elapsedMs": 552, "count": 137 },
{ "type": "ping", "elapsedMs": 0 },
{ "type": "listSessions", "elapsedMs": 547, "count": 137 },
{ "type": "ping", "elapsedMs": 0 }
]
```
A second daemon-only queue test sent two `listSessions` requests and then a `ping` from another client:
```json
{
"totalMs": 1069,
"listSessions1Ms": 1068,
"listSessions2Ms": 1068,
"pingBehindListSessionsMs": 1034,
"sessions": 137
}
```
This reproduces the same stall shape without terminal input or UI switching: a control request sent behind resume-time `listSessions` waits about one second.
Source cause:
- `TerminalHost.listSessions()` loops every live daemon session.
- For each session, it calls `session.getSnapshot()` only to read `cols` and `rows`.
- `getSnapshot()` serializes the headless xterm buffer, so a liveness/session-list request scales with terminal scrollback/state across the whole profile.
- Renderer visibility resume calls `window.api.pty.listSessions()` for dead-session reconciliation. With about 137 live daemon sessions, one resume-time list was about 550 ms; two back-to-back resumes were about 1.0-1.1 seconds.
## What This Rules Out
- It is probably not keyboard focus. Direct daemon writes reproduce the delay.
- It is probably not only renderer paint. Direct daemon writes wait before the shell receives bytes.
- It is probably not bash or node-pty readiness. No-switch direct writes are fast, and receipt latency after a delayed daemon response is low.
- It is probably not queueing only on Orca's normal daemon client socket. The direct-daemon harness uses a separate socket and still sees the delay.
- It is not caused by typing itself. A ping loop with no write showed the daemon stall during switching.
- It is unlikely to be a single normal `getSnapshot` or `resize` call, because direct controls for those operations are much cheaper than the observed stall.
- The resume-time dead-pane recovery is still necessary; the hot path should
use single-PTY liveness, not a global session list.
## Current Conclusion
Switching workspaces or terminals can create a short daemon/main busy window
when warm resume triggers global session enumeration. During that window, even a
direct terminal `write` request waits before the daemon services it. Once the
daemon services the write, the shell receives and processes the bytes quickly.
The confirmed root for the remaining warm-switch lag is the visibility-resume
dead-session reconciliation path calling global `listSessions()` in profiles
with many preserved daemon sessions. The original dead-session recovery is valid;
the expensive primitive was the problem.
## Leading Hypotheses
1. Confirmed: resume-time dead-session reconciliation called daemon
`listSessions`, and older daemons synchronously snapshot every live session
to return cols/rows.
2. Confirmed: avoiding global `listSessions()` on warm resume removes the
request that queued ahead of first post-switch input.
3. Possible secondary contributor: switching to certain old or output-rich
workspaces may also reattach existing daemon-backed PTYs. The daemon
`createOrAttach` path synchronously calls `existing.getSnapshot()` before
responding.
4. Possible secondary contributor: hidden-output recovery, pending-output
draining, or another snapshot-like serialization path runs synchronously on
resume and delays request handling.
5. Less likely: workspace or terminal resume triggers visible-terminal
resize/SIGWINCH or TUI repaint output. The unchanged `lastOutputAt`
observation currently makes this less likely than `listSessions`.
## Relevant Code Areas
- CLI terminal send: `src/cli/handlers/terminal.ts`
- Runtime terminal send/focus/write: `src/main/runtime/orca-runtime.ts`
- Runtime PTY data handling: `onPtyData`, `trackHeadlessTerminalData`, hidden-output serialization in `src/main/runtime/orca-runtime.ts`
- Daemon request routing: `src/main/daemon/daemon-server.ts`
- Daemon session write/resize/output handling: `src/main/daemon/session.ts`
- Daemon-side headless terminal state: `src/main/daemon/headless-emulator.ts`
- Daemon adapter: `src/main/daemon/daemon-pty-adapter.ts`
- Main IPC PTY controller: `src/main/ipc/pty.ts`
- Renderer terminal resume: `src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts`
- Renderer PTY connection and resume size reassertion: `src/renderer/src/components/terminal-pane/pty-connection.ts`
- Renderer output scheduler: `src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts`
- Existing-session reattach snapshot: `TerminalHost.createOrAttach` in `src/main/daemon/terminal-host.ts`
- Session snapshot serialization: `TerminalSession.getSnapshot()` in `src/main/daemon/session.ts`
- Snapshot cost benchmark: `src/main/daemon/headless-emulator-snapshot-cost.bench.test.ts`
## Current Root-Cause Theory
The root cause is using global session enumeration as a per-pane liveness check:
1. Workspace switching makes a terminal pane visible again.
2. The renderer schedules dead-session reconciliation on hidden-to-visible resume.
3. The old reconcile path called `window.api.pty.listSessions()`, which reaches
the daemon `listSessions` RPC.
4. Older preserved daemons implement `TerminalHost.listSessions()` by calling
`session.getSnapshot()` for every live session.
5. With a heavy profile, those snapshot serializations block the daemon event
loop for about 550 ms per list.
6. A switch away/back can put a `write` behind two resume-time lists, yielding
about 1.0-1.6 seconds of delayed input.
This theory fits the current evidence:
- Direct daemon writes block, so the delay is below keyboard focus and normal CLI plumbing.
- Receipt handling after the daemon write response is fast, so the shell and PTY are not the bottleneck.
- No-switch writes are fast.
- Waiting after switching away lets the expensive resume work finish, so switching back and typing is fast.
- `lastOutputAt` does not advance, so the child process probably is not generating the expensive work.
- Synthetic fresh heavy output did not reproduce reliably, which points toward retained old session/state, request fanout, or workspace-specific resume behavior rather than simple line count.
The correct fix is two-layered:
1. Keep daemon `listSessions` metadata-only for builds where callers genuinely
need the global session list.
2. Do not use `listSessions` for warm-resume dead-pane recovery. Use
`pty:hasPty(id)` to ask about the pane's own PTY id, backed by provider
in-memory state. Close only on authoritative `false`; fail open on `true`,
`null`, unsupported providers, remote-runtime/SSH ids, and stale/newborn
races.
## Investigation Constraints
- Do not kill or restart the user's main packaged Orca app or daemon without explicit approval.
- Use throwaway terminals for probes and close them afterward.
- Keep temp harnesses and screenshots out of commits unless explicitly requested.
- Reproduce in the real main-app flow when possible; lighter dev profiles may not show the problem.
## Verification Plan
1. Unit-test that `pty:hasPty(id)` does not call provider `listProcesses()`.
2. Unit-test that targeted liveness still closes a missing local PTY and fails
open for live/unknown/stale cases.
3. Unit-test that terminal input and warm resume do not call `listSessions()`.
4. Re-run the headful fullscreen warm-switch E2E with `pty:listSessions`
artificially delayed and assert the count stays zero.
5. Re-run the main-app direct-daemon switch/no-switch harness when validating
against the user's heavy profile.